# Integrating authbase

> The API calls and token checks for any language, with Node, Go and Python code and the error codes.

Your backend talks to authbase for four things: registering users, logging
them in, refreshing, and logging out. Everything else, above all verifying
the access token on each request, happens **locally** against the app's
JWKS. authbase is never on your hot path.

For Node and the JVM, the SDKs do all of this for you:
[`@authbase/node`](../sdk/typescript/README.md) (Express, Hono, Next.js) and
[the Kotlin SDK](https://authbase.burakmetehan.com.tr/docs/sdk/kotlin.md) (Ktor, Spring Boot). Working
backends for each, and for Go without an SDK, are in
[the examples](https://authbase.burakmetehan.com.tr/docs/examples.md); the [quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md)
runs one end to end. This page is the contract underneath, for any
language.

## 1. Get an app and an API key

In the dashboard (**New app**, then **API keys → Create API key**), or with
the CLI on the server:

```sh
authbase admin create-account --email you@example.com --password-stdin
authbase admin create-app --owner you@example.com --slug cashmate
authbase admin create-api-key --app cashmate --name production   # prints sk_live_…
```

Note the app's issuer and JWKS URL, printed by `create-app`:

```
issuer:    https://auth.example.com/apps/cashmate
jwks_uri:  https://auth.example.com/apps/cashmate/.well-known/jwks.json
```

## 2. Register and log users in (server to server)

Every call carries `Authorization: Bearer sk_live_…`. Requests and
responses are JSON; errors are RFC 9457 problem documents with a stable
`code` (see the [API reference](https://authbase.burakmetehan.com.tr/api/)).

```sh
# register
curl -s https://auth.example.com/v1/users \
  -H 'Authorization: Bearer sk_live_…' -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse battery","username":"ada"}'

# log in; pass the end-user's IP so rate limiting and audit see it
curl -s https://auth.example.com/v1/auth/login \
  -H 'Authorization: Bearer sk_live_…' -H 'Content-Type: application/json' \
  -d '{"identifier":"ada@example.com","password":"correct horse battery","client_ip":"203.0.113.9"}'
```

The login response:

```json
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900,
  "refresh_token": "rt_…", "refresh_expires_in": 2592000,
  "user": { "id": "…", "email": "ada@example.com", "roles": [], … } }
```

Hand both tokens to your client (cookie or body). When the access token
expires, `POST /v1/auth/refresh` with the refresh token: you get a **new**
pair and the old refresh token is dead. If a dead one is ever presented
again, the whole family is revoked and the next refresh fails with
`refresh_token_reused`. `POST /v1/auth/logout` revokes the family.

## 3. Verify access tokens locally

Rules, in every language:

1. Fetch the JWKS from the app's `jwks_uri` and cache it (`Cache-Control:
   max-age=300`). Refetch when a `kid` is unknown (key rotation).
2. Pick the key by the token's `kid`. **Never** take keys from the token
   (`jku`, `x5u`, `jwk` headers) or from anywhere but your configured
   JWKS URL.
3. Accept only `alg: EdDSA`. Reject `none`, `HS*`, `RS*`.
4. Check `iss` equals the app's issuer, `aud` contains the app id, `exp`
   is in the future.
5. Check the header `typ` is `at+jwt` (RFC 9068). Other JWTs signed by the
   same key, such as ID tokens from the planned hosted login, are not access
   tokens.

Claims: `sub` (user id), `email`, `email_verified`, `username` (absent when
unset), `roles`, `sid` (login lineage), `amr`, `jti`, `client_id` (the app
id, as `aud`). User `metadata` is
**not** in the token; fetch the user when you need it.

### Node (jose, standard library only otherwise)

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://auth.example.com/apps/cashmate";
const APP_ID = "01a0…";          // the app's id (aud)
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: ISSUER,
    audience: APP_ID,
    algorithms: ["EdDSA"],
    typ: "at+jwt",
  });
  return payload; // payload.sub, payload.email, payload.roles, …
}
```

### Go (lestrrat-go/jwx)

```go
import (
    "context"
    "errors"
    "sync"
    "time"

    "github.com/lestrrat-go/httprc/v3"
    "github.com/lestrrat-go/jwx/v3/jwk"
    "github.com/lestrrat-go/jwx/v3/jws"
    "github.com/lestrrat-go/jwx/v3/jwt"
)

const issuer = "https://auth.example.com/apps/cashmate"
const appID = "01a0…"

var (
    cache       *jwk.Cache
    mu          sync.Mutex
    lastRefresh time.Time
)

const jwksURL = issuer + "/.well-known/jwks.json"

// Fails fast at startup if authbase is unreachable or the JWKS is invalid.
func init() {
    var err error
    // NewCache needs an unstarted httprc client; passing nil panics.
    if cache, err = jwk.NewCache(context.Background(), httprc.NewClient()); err != nil {
        panic(err)
    }
    if err = cache.Register(context.Background(), jwksURL, jwk.WithMinInterval(5*time.Minute)); err != nil {
        panic(err)
    }
}

// verifyAccessToken refetches the JWKS once (at most once a second) when a
// token fails, so a signing-key rotation is picked up at once instead of
// at the next scheduled refresh.
func verifyAccessToken(ctx context.Context, token string) (jwt.Token, error) {
    tok, err := parse(ctx, []byte(token))
    if err == nil || !mayRefresh() {
        return tok, err
    }
    if _, rerr := cache.Refresh(ctx, jwksURL); rerr != nil {
        return nil, errors.Join(err, rerr)
    }
    return parse(ctx, []byte(token))
}

func mayRefresh() bool {
    mu.Lock()
    defer mu.Unlock()
    if time.Since(lastRefresh) < time.Second {
        return false
    }
    lastRefresh = time.Now()
    return true
}

func parse(ctx context.Context, token []byte) (jwt.Token, error) {
    set, err := cache.Lookup(ctx, jwksURL)
    if err != nil {
        return nil, err
    }
    tok, err := jwt.Parse(token,
        jwt.WithKeySet(set, jws.WithInferAlgorithmFromKey(false)), // kid + alg from the JWKS only
        jwt.WithIssuer(issuer),
        jwt.WithAudience(appID),
    )
    if err != nil {
        return nil, err
    }
    // typ is covered by the signature Parse just verified.
    msg, err := jws.Parse(token)
    if err != nil {
        return nil, err
    }
    if typ, _ := msg.Signatures()[0].ProtectedHeaders().Type(); typ != "at+jwt" {
        return nil, errors.New("not an access token")
    }
    return tok, nil
}
```

Private array claims such as `roles` come back from jwx as `[]any`:
read them with `var roles []any; tok.Get("roles", &roles)` and convert
each element (examples/go has a helper).

### Python (PyJWT)

```python
import jwt
from jwt import PyJWKClient

ISSUER = "https://auth.example.com/apps/cashmate"
APP_ID = "01a0…"
jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json", cache_keys=True)

def verify_access_token(token: str) -> dict:
    key = jwks.get_signing_key_from_jwt(token)      # picks by kid
    verified = jwt.decode_complete(
        token, key.key, algorithms=["EdDSA"],
        issuer=ISSUER, audience=APP_ID,
    )
    if verified["header"].get("typ") != "at+jwt":   # RFC 9068
        raise jwt.InvalidTokenError("not an access token")
    return verified["payload"]
```

## 4. Errors you will see

| Situation | Status | `code` |
|---|---|---|
| Wrong password or unknown identifier | 401 | `invalid_credentials` |
| Too many failures for an identifier | 429 | `account_locked` (+ `Retry-After`) |
| Too many requests from one IP / key | 429 | `rate_limited` |
| Disabled user | 403 | `user_disabled` |
| Email verification required (app setting) | 403 | `email_not_verified` |
| Refresh token unknown / expired / replayed | 401 | `refresh_token_invalid` / `refresh_token_expired` / `refresh_token_reused` |
| Invalid request body | 422 | `validation_failed` with `errors[]` |
| New password too common | 422 | `password_breached` (field named in `errors[]`) |
| Reset or verification link unknown / expired / used | 401 / 401 / 409 | `token_invalid` / `token_expired` / `token_already_used` |
| Email needed but the instance has no SMTP | 503 | `email_not_configured` |
| Bad or revoked API key | 401 | `invalid_api_key` |

Every problem document carries a `request_id`; quote it when reporting an
issue, it matches the server's log line.

## 5. Password reset and email verification

authbase sends these emails itself (the operator configures SMTP), so your
backend makes one call each and never handles the mail.

**Password reset.** When a user clicks "forgot password":

```sh
curl -s -X POST $AUTHBASE/v1/auth/password-reset/request \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","client_ip":"203.0.113.9"}'
# 202 always, registered or not: the response reveals nothing
```

The email links to authbase's hosted page
(`{PUBLIC_URL}/apps/{slug}/reset-password`), which works without JavaScript and
finishes the job: the user chooses a password, every refresh token of the
user stops working, and the address counts as verified. Links are single
use and expire after 30 minutes; a newer link replaces older ones.

To keep users on your own site, set the app's `password_reset_url` (for
example `https://app.example.com/reset`). The email then links to
`https://app.example.com/reset?token=…`, and your page posts the token and
the new password:

```sh
curl -s -X POST $AUTHBASE/v1/auth/password-reset/confirm \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"token":"<from the link>","new_password":"<the user'"'"'s choice>"}'
# 204; 422 password_breached leaves the link usable
```

**Email verification.** Set the app's `email_verification`:

| Mode | On sign-up (`POST /v1/users`) | At login |
|---|---|---|
| `off` (default) | nothing is sent | never checked |
| `optional` | verification email sent | never blocks; `email_verified` tells you |
| `required` | verification email sent | `403 email_not_verified` until confirmed |

Send another link with `POST /v1/auth/email-verification/request`
(`{"user_id": …}`); it works in every mode. The hosted page
(`/apps/{slug}/verify-email`) shows a "Confirm" button rather than
confirming on open, so mail filters that follow links cannot verify an
address. With your own `email_verification_url`, post the token to
`POST /v1/auth/email-verification/confirm`. Links expire after 24 hours.

**Security notices.** authbase tells users when their password changes and
tells the old address when their email changes. If you send your own,
set the app's `security_notifications` to `false`.

## 6. Key rotation

Rotating the signing key (dashboard or `POST /v1/apps/{id}/signing-keys/rotate`)
adds a new key to the JWKS while the old one stays published until every
token it signed has expired. A verifier that refetches the JWKS on an
unknown `kid` needs no restart. Rotating API keys gives the old key a grace
period (default 24 h) so you can roll deployments.
