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:
- Signature against the pool’s JWKS (
EdDSA/ Ed25519) issequals your pool issuer exactlyaudequals 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, notapp_roles— permissions come from the pool’s role catalog, so you can re-scope a role without redeploying your backend. - Multi-tenant apps:
tenantsmaps slug → roles for every membership; when sign-in was pinned (tenant=acme-uk), the token also carries a top-leveltenantand 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-keysthe old key stays published, so in-flight tokens verify through the overlap. - Want the server-side view of a token holder?
GET {issuer}/userwith the bearer returns{claims, user}— handy in development; in production verify locally.