Integrating authbase
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 (Express, Hono, Next.js) and
the Kotlin SDK (Ktor, Spring Boot). Working
backends for each, and for Go without an SDK, are in
the examples; the quick start
runs one end to end. This page is the contract underneath, for any
language.
1. Get an app and an API key
Section titled “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:
authbase admin create-account --email you@example.com --password-stdinauthbase admin create-app --owner you@example.com --slug cashmateauthbase 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/cashmatejwks_uri: https://auth.example.com/apps/cashmate/.well-known/jwks.json2. Register and log users in (server to server)
Section titled “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).
# registercurl -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 itcurl -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:
{ "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
Section titled “3. Verify access tokens locally”Rules, in every language:
- Fetch the JWKS from the app’s
jwks_uriand cache it (Cache-Control: max-age=300). Refetch when akidis unknown (key rotation). - Pick the key by the token’s
kid. Never take keys from the token (jku,x5u,jwkheaders) or from anywhere but your configured JWKS URL. - Accept only
alg: EdDSA. Rejectnone,HS*,RS*. - Check
issequals the app’s issuer,audcontains the app id,expis in the future. - Check the header
typisat+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)
Section titled “Node (jose, standard library only otherwise)”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)
Section titled “Go (lestrrat-go/jwx)”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)
Section titled “Python (PyJWT)”import jwtfrom 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
Section titled “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
Section titled “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”:
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 nothingThe 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:
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 usableEmail 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
Section titled “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.
For agents: this page as markdown · llms.txt