Skip to Content
We are live but in Staging 🎉
RecipesVerify Tokens in Your Backend

Verify tokens in your backend

Your backend accepts a pool’s access tokens by verifying them locally — no call to AppID per request, no shared secret. Three checks, always all three:

  1. Signature against the pool’s JWKS (EdDSA / Ed25519)
  2. iss equals your pool issuer exactly
  3. aud equals your pool audience (pool:<name>)

The iss+aud pair is what keeps the two planes apart — a platform token, or a token from someone else’s pool, fails these checks even though it’s a perfectly valid JWT.

Node (jose)

import { createRemoteJWKSet, jwtVerify } from 'jose' const ISSUER = 'https://appid.dodil.io/acme/my-app' const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)) export async function requireUser(req) { const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') const { payload } = await jwtVerify(token, jwks, { issuer: ISSUER, audience: 'pool:my-app', }) return payload // sub, email, app_roles, permissions, tenants, … }

createRemoteJWKSet caches keys and re-fetches on unknown kid — key rotation just works.

Python (PyJWT)

import jwt ISSUER = "https://appid.dodil.io/acme/my-app" jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json") def require_user(token: str) -> dict: key = jwks.get_signing_key_from_jwt(token) return jwt.decode( token, key.key, algorithms=["EdDSA"], issuer=ISSUER, audience="pool:my-app", )

Go (lestrrat-go/jwx)

issuer := "https://appid.dodil.io/acme/my-app" cache := jwk.NewCache(ctx) cache.Register(issuer + "/.well-known/jwks.json") keyset, _ := cache.Get(ctx, issuer+"/.well-known/jwks.json") tok, err := jwt.Parse(raw, jwt.WithKeySet(keyset), jwt.WithIssuer(issuer), jwt.WithAudience("pool:my-app"), )

Authorize on the claims

{ "iss": "https://appid.dodil.io/acme/my-app", "aud": "pool:my-app", "sub": "8f2c…", "email": "[email protected]", "connection": "local", "app_roles": ["admin"], "permissions": ["users.write", "billing.read"], "tenants": { "acme-uk": ["manager"] }, "amr": ["pwd"], "iat": 1767950000, "exp": 1767950900 }
  • Gate on permissions, not app_roles — permissions come from the pool’s role catalog, so you can re-scope a role without redeploying your backend.
  • Multi-tenant apps: tenants maps slug → roles for every membership; when sign-in was pinned (tenant=acme-uk), the token also carries a top-level tenant and its role permissions merged in.
  • Pin algorithms/verification to EdDSA only — never accept whatever alg the token header claims.

Practical notes

  • Access tokens live 15 minutes by default (settings.access_ttl_secs) — that’s your revocation latency for bans and role changes. Session revocation kills the refresh family immediately; issued access tokens ride out their TTL.
  • The JWKS is served with a 5-minute cache header. After pool rotate-keys the old key stays published, so in-flight tokens verify through the overlap.
  • Want the server-side view of a token holder? GET {issuer}/user with the bearer returns {claims, user} — handy in development; in production verify locally.