Validate Access Tokens in Your API 🛡️
Your frontend or a machine-to-machine client sends requests to your backend with an access token:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...This guide shows how to verify that token in your API — Node.js/Express in the examples, but the checks are the same in any stack. Validation is fully local: your API verifies the token’s signature against your tenant’s public keys, so there’s no call to Faable on each request.
A valid token must pass four checks:
- Signature — RS256, against your tenant’s JWKS (public keys).
- Issuer (
iss) — your tenant URL, exactly. - Audience (
aud) — theidentifierof your registered API. - Expiry (
exp) — handled automatically by any JWT library.
Then you authorize the request using the token’s scope / permissions claims — and, if you enable them, teams / roles.
Protecting Next.js routes instead of an API? This guide is for verifying a
Bearertoken your backend receives (typically machine-to-machine or a SPA calling your API). To gate Next.js pages, Route Handlers, or middleware by the user’s session cookie, read it withgetSessionFromCookies— see Next.js (Server-Side).
🔍 What’s Inside a Faable Access Token
Access tokens are RS256-signed JWTs. Decoded, they look like this:
{
"sub": "user_66f1a2b3c4d5e6f7a8b9c0d1",
"scope": "openid profile email read:orders",
"permissions": "read:orders",
"client_id": "AbC123xYz...",
"account": "acc_6612ab34cd56ef7890ab12cd",
"aud": "https://api.myapp.com",
"iss": "https://your-domain.auth.faable.link",
"iat": 1751641200,
"exp": 1751727600
}| Claim | Description |
|---|---|
sub | Who the token represents: a user id (user_…) for login flows, or the client_id for machine-to-machine tokens. |
scope | Space-separated string of granted scopes. |
permissions | Space-separated string (not an array) of granted API permissions. Only present when your API uses the access_token_authz token dialect — see APIs. |
client_id | The client that obtained the token. Useful for logging and for per-client rules. |
account | Your tenant’s id. Every token carries it; you can ignore it for validation because iss already pins the tenant. |
aud | The identifier of the API the token was issued for. When no audience was requested it falls back to <tenant>/userinfo — see Pitfalls. |
iss | Your tenant URL — no trailing slash. Custom-domain tenants issue with the custom domain (https://login.yourapp.com). |
exp | Expiry (seconds since epoch). Lifetime comes from your API’s token_lifetime (default 24 h). |
teams / roles | Optional string arrays, included when your API enables include_teams_in_access_token / include_roles_in_access_token. Use them for team- or role-based authorization without a database lookup per request. |
Your tenant publishes its public keys at https://your-domain.auth.faable.link/.well-known/jwks.json and its OIDC metadata at https://your-domain.auth.faable.link/.well-known/openid-configuration. Keys are per tenant and rotate — always resolve them by the token header’s kid via JWKS instead of pinning a fixed key (any JWKS library does this for you).
💻 Express Middleware with jose
The complete pattern using jose, which caches the JWKS and re-fetches it on key rotation automatically:
import express from 'express'
import { type JWTPayload, createRemoteJWKSet, jwtVerify } from 'jose'
const ISSUER = 'https://your-domain.auth.faable.link' // your tenant — NO trailing slash
const AUDIENCE = 'https://api.myapp.com' // your API's identifier in the dashboard
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
async function checkJwt(req, res, next) {
const token = req.headers.authorization?.replace(/^Bearer /i, '')
if (!token) {
res.set('WWW-Authenticate', 'Bearer')
return res.status(401).json({ error: 'missing_token' })
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: AUDIENCE, // also rejects OIDC-only tokens issued without an audience
algorithms: ['RS256']
})
req.auth = payload // exp/iat already validated by jwtVerify
next()
} catch {
res.set('WWW-Authenticate', 'Bearer error="invalid_token"')
res.status(401).json({ error: 'invalid_token' })
}
}
// Authorize per-route: `scope` and `permissions` are space-separated strings
const requireScope = (required: string) => (req, res, next) => {
const granted = new Set(
`${req.auth.scope ?? ''} ${req.auth.permissions ?? ''}`.split(/\s+/)
)
if (!granted.has(required)) {
res.set(
'WWW-Authenticate',
`Bearer error="insufficient_scope", scope="${required}"`
)
return res.status(403).json({ error: 'insufficient_scope' })
}
next()
}
const app = express()
app.get('/orders', checkJwt, requireScope('read:orders'), (req, res) => {
res.json({ orders: [], for: req.auth.sub })
})
app.listen(3000)Two status codes, on purpose: 401 means “I can’t trust who you are” (missing, expired, wrong signature, wrong audience) and 403 means “I know who you are and you’re not allowed” (scope missing). The WWW-Authenticate header is what RFC 6750 prescribes; SDKs and API gateways use it to decide whether to refresh the token or give up.
The same four checks, mapped to library options
| Check | jose (Node) | PyJWT (Python) |
|---|---|---|
| Signature | createRemoteJWKSet + kid | PyJWKClient(...).get_signing_key_from_jwt |
| Issuer | issuer: ISSUER | issuer=ISSUER |
| Audience | audience: AUDIENCE | audience=AUDIENCE |
| Expiry | automatic | automatic |
| Algorithm | algorithms: ['RS256'] | algorithms=["RS256"] |
Python, in twelve lines (the full FastAPI version with dependencies and scope enforcement is in the FastAPI Quickstart):
import jwt # PyJWT
ISSUER = "https://your-domain.auth.faable.link"
AUDIENCE = "https://api.myapp.com"
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json") # caches keys, handles rotation
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(token, key, algorithms=["RS256"], issuer=ISSUER, audience=AUDIENCE)
def has_scope(claims: dict, required: str) -> bool:
granted = set(f"{claims.get('scope', '')} {claims.get('permissions', '')}".split())
return required in grantedGo, Java and .NET all have JWKS-aware JWT libraries; point them at your tenant’s discovery URL and set issuer, audience and RS256 the same way.
⚠️ Pitfalls That Cause “Invalid Token”
These are the mistakes we see most often, especially when porting middleware from other providers:
- Trailing slash on the issuer. Faable’s
issishttps://your-domain.auth.faable.link— no trailing slash. Auth0-style configs with a trailing/reject every token. - Treating
permissionsas an array. Faable emits it as a space-separated string; Auth0 emits an array.payload.permissions.includes("read:orders")on a string does a substring match —read:orderswould “match”read:orders_archive. Split on whitespace first (asrequireScopeabove does). - Skipping the
audiencecheck. A token requested without anaudienceis still valid OIDC-wise, but itsaudishttps://your-domain.auth.faable.link/userinfo— it was only ever meant for the UserInfo endpoint, not your API. Always validateaudagainst your API identifier; that single check rejects these. If you see thisaudin your logs, the client forgot to sendaudienceon/authorizeor/oauth/token. - Validating access tokens against the
client_id. The ID token’saudis the client id; the access token’saudis the API identifier. Your backend validates access tokens — use the API identifier. - Verifying the ID token instead of the access token. The ID token proves who logged in to the app that requested it; it is not meant for your API and its audience won’t match. Clients should send the access token.
- Pinning a single public key. Tenant signing keys rotate. Resolve keys via JWKS using the token header’s
kid(remote JWKS helpers handle caching and rotation). - Custom domain mismatch. If your tenant runs on a custom domain, tokens are issued with that domain as
iss. Configure the issuer your clients actually log in through, and fetch the JWKS from the same host.
🧪 Testing Your Middleware
Get a real token for your API with the Client Credentials flow and call your endpoint:
TOKEN=$(curl -s -X POST 'https://your-domain.auth.faable.link/oauth/token' \
-H 'content-type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"...","client_secret":"...","audience":"https://api.myapp.com"}' \
| jq -r .access_token)
curl -i http://localhost:3000/orders -H "authorization: Bearer $TOKEN"Then break it on purpose — the checks are only proven once each one has rejected something:
curl -i http://localhost:3000/orders # 401 missing_token
curl -i http://localhost:3000/orders -H "authorization: Bearer ${TOKEN}x" # 401 invalid_token (bad signature)Request the token without audience and you should get a 401 too — that is the aud check doing its job. Decode any token you’re unsure about with jwt.io (client-side only; nothing is sent).
❓ FAQ
Does my API call Faable on every request?
No. Verification is local — your API checks the RS256 signature against the JWKS, which the library caches. Faable is only contacted when the JWKS cache is cold or a new kid appears (key rotation).
What’s the difference between scope and permissions?
scope is what the client requested and was granted (standard OAuth). permissions appears when your API uses the access_token_authz dialect: with enforce_policies enabled it’s the requested scopes filtered against your API’s permission catalog, so it’s the claim to trust for authorization. Without enforce_policies the requested scopes are echoed verbatim. See APIs.
Why is there no permissions claim in my token?
Your API is using the default access_token dialect, which never emits it. Switch the API’s token dialect to access_token_authz in the dashboard, or authorize on scope instead.
Why does my token say aud: "https://…/userinfo"?
The client didn’t ask for your API. Add audience=https://api.myapp.com to the /authorize request (or to the /oauth/token request for client credentials). Your API is right to reject such a token.
How do I know if the caller is a user or a machine?
Look at sub: user tokens carry a user_… id; machine-to-machine tokens carry the client’s client_id.
How do I authorize by team or role?
Enable include_teams_in_access_token / include_roles_in_access_token on your API. The token then carries teams and roles as string arrays, so a route can check req.auth.roles?.includes('admin') with no database lookup. Membership changes show up on the next token, not the current one — keep token_lifetime short if that matters.
How long is an access token valid?
24 hours by default, configurable per API through token_lifetime. A token stays valid until exp even if the user is suspended or logs out — a shorter lifetime bounds that window, and the Refresh Token flow keeps sessions alive without long-lived access tokens.
Can I use a library other than jose?
Yes — any JWT library with JWKS support works (express-oauth2-jwt-bearer, jwks-rsa + jsonwebtoken, or your stack’s equivalent). Configure it with your tenant issuer (no trailing slash), your API identifier as audience, and RS256.
How do I do this in Python?
Same four checks with PyJWT — PyJWKClient handles the JWKS cache and key rotation; the snippet above is the whole thing. See the FastAPI Quickstart for the complete dependency-based version, including scope enforcement.
🔗 Related
- FastAPI Quickstart — the Python version of this guide, with PyJWT and FastAPI dependencies.
- APIs — register your API, define its
identifier(audience), token dialect and permission catalog. - Client Credentials Flow — how machine clients obtain the tokens your API validates.
- Authorization Code Flow — how user tokens are issued, with
@faable/auth-js. - UserInfo Endpoint — what a token without an audience is actually for.
- RFC 6750 — Bearer Token Usage , RFC 7517 — JSON Web Key (JWK) and RFC 9068 — JWT Access Tokens — the standards involved.
Last updated on