Skip to Content
🔐 Faable AuthValidate Access Tokens

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:

  1. Signature — RS256, against your tenant’s JWKS (public keys).
  2. Issuer (iss) — your tenant URL, exactly.
  3. Audience (aud) — the identifier of your registered API.
  4. 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 Bearer token 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 with getSessionFromCookies — 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 }
ClaimDescription
subWho the token represents: a user id (user_…) for login flows, or the client_id for machine-to-machine tokens.
scopeSpace-separated string of granted scopes.
permissionsSpace-separated string (not an array) of granted API permissions. Only present when your API uses the access_token_authz token dialect — see APIs.
client_idThe client that obtained the token. Useful for logging and for per-client rules.
accountYour tenant’s id. Every token carries it; you can ignore it for validation because iss already pins the tenant.
audThe identifier of the API the token was issued for. When no audience was requested it falls back to <tenant>/userinfo — see Pitfalls.
issYour tenant URL — no trailing slash. Custom-domain tenants issue with the custom domain (https://login.yourapp.com).
expExpiry (seconds since epoch). Lifetime comes from your API’s token_lifetime (default 24 h).
teams / rolesOptional 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

Checkjose (Node)PyJWT (Python)
SignaturecreateRemoteJWKSet + kidPyJWKClient(...).get_signing_key_from_jwt
Issuerissuer: ISSUERissuer=ISSUER
Audienceaudience: AUDIENCEaudience=AUDIENCE
Expiryautomaticautomatic
Algorithmalgorithms: ['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 granted

Go, 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 iss is https://your-domain.auth.faable.link — no trailing slash. Auth0-style configs with a trailing / reject every token.
  • Treating permissions as 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:orders would “match” read:orders_archive. Split on whitespace first (as requireScope above does).
  • Skipping the audience check. A token requested without an audience is still valid OIDC-wise, but its aud is https://your-domain.auth.faable.link/userinfo — it was only ever meant for the UserInfo endpoint, not your API. Always validate aud against your API identifier; that single check rejects these. If you see this aud in your logs, the client forgot to send audience on /authorize or /oauth/token.
  • Validating access tokens against the client_id. The ID token’s aud is the client id; the access token’s aud is 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.


Last updated on