Skip to content
Early preview. authbase is in active development. The source code and packages are not public yet; these docs describe how it works ahead of the first public release.

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.

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

Terminal window
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)

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).

Terminal window
# 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:

{ "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.

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)

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, …
}
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).

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"]
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.

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”:

Terminal window
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:

Terminal window
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.

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.