Trust the bearer your customer sent you.
Your backend receives an Authorization header. Before you act on it, prove the token is real, fresh, and authorised. Auth gives you three shapes — pick the right one for the route.
1
The three shapes at a glance
| Shape | Endpoint | When |
|---|---|---|
| Local JWKS verify | your service, jose/PyJWT | Per-request, hot paths. Zero Auth round-trip. |
| Introspect | POST /:slug/v1/oauth/introspect | RFC 7662 shape, when you need server-side revocation checks. |
| Verify + authorize | POST /:slug/v1/verify, /authorize | When you also want a permission check in the same call. |
2
Local JWKS verification — the fast path
Fetch the app's JWKS once, cache it, verify every token with the cached keys. No network call per request.
Auth publishes the public half of each app's signing keys at /<appSlug>/v1/.well-known/jwks.json. Your backend fetches this once on boot (or lazily on first request), caches the result for an hour, and verifies every bearer locally.
import { Auth, JwtExpiredError } from "@productcraft/auth";
const auth = new Auth();
const scope = auth.consumer("acme");
export async function verifyToken(bearer: string) {
try {
const claims = await scope.verifyToken(bearer);
return claims; // { sub, aid, sid, role, type, amr, org_id?, org_role?, iat, exp, iss, aud }
} catch (err) {
if (err instanceof JwtExpiredError) {
// trigger a refresh flow
}
throw err;
}
}The SDK version uses the same primitive (jose.createRemoteJWKSet under the hood) but wraps it with a typed claims interface, typed error classes (JwtExpiredError, JwtInvalidError, ...), automatic issuer pinning to auth.consumer(slug).expectedIssuer, and singleflighted JWKS refetch on kid-miss. The jose-direct block is here if you'd rather wire it yourself.
On kid miss (Auth rotated a key) the library refreshes the JWKS automatically. Rotated-out keys stay published in the JWKS until every token they signed has expired, so rotation never invalidates a live token.
import jwt
from jwt import PyJWKClient
JWKS_URL = 'https://api.auth.productcraft.co/acme/v1/.well-known/jwks.json'
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True)
def verify_token(bearer: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(bearer)
return jwt.decode(
bearer,
signing_key.key,
algorithms=['RS256'],
audience='acme', # app slug
issuer='https://api.auth.productcraft.co/acme', # per-app
)3
What the payload tells you
An EndUser access token:
{
"sub": "648616c8-...", // EndUser id
"aid": "53641fdb-...", // app id
"sid": "c0a35ee6-...", // session id
"role": "member",
"type": "end_user",
"amr": ["pwd"], // how they authenticated
"org_id": "f9b3..." | undefined, // tenant when active
"org_role": "admin" | undefined,
"iat": 1778499565,
"exp": 1778503165, // iat + access_token_ttl (1h default)
"iss": "https://api.auth.productcraft.co/acme", // per-app
"aud": "acme" // app slug
}An M2M access token:
{
"sub": "m2m_a1b2c3...", // client_id
"aid": "53641fdb-...",
"type": "m2m",
"scopes": ["user.read", "invoice.read"],
"iat": ...,
"exp": ...,
"iss": "https://api.auth.productcraft.co/acme", // per-app
"aud": "acme" // app slug
}Your service reads sub + aid to establish identity, type to branch on EndUser vs M2M behaviour, and role / scopes for permission gating.
4
Resolve permissions per request
The role name in the JWT isn't enough — you need the flat permission set to gate routes. Auth provides /me/permissions for this.
curl https://api.auth.productcraft.co/acme/v1/me/permissions \
-H 'authorization: Bearer <end-user-access-token>'{
"role": "member",
"org_role": "admin",
"permissions": ["user.read", "invoice.read", "invoice.create", "invoice.refund"]
}The permissions array is the union of the app-level role's permissions and (if org_id is set) the tenant-level role's permissions. Resolved per-request through Auth's 60s LRU — role-permission edits propagate within a minute.
Your service caches the response per token+session for a short window (60s mirrors Auth's own cache) and checks set membership before letting the request through.
5
Introspect — when you need server-side revocation
Local JWKS verification can't tell you the session was revoked five minutes ago — the token is still cryptographically valid until exp. Introspection asks Auth the live question.
curl https://api.auth.productcraft.co/acme/v1/oauth/introspect \
-H 'authorization: Bearer <the-token-you-want-to-introspect>'RFC 7662 shape:
# Valid EndUser token
{ "active": true, "sub": "648616c8-...", "type": "end_user", "exp": ..., "iat": ..., "iss": "https://api.auth.productcraft.co/acme", "aid": "53641fdb-..." }
# Valid M2M token — adds client_id + scopes
{ "active": true, "sub": "m2m_a1b2...", "client_id": "m2m_a1b2...", "type": "m2m", "scopes": ["user.read"], "scope": "user.read", ... }
# Revoked / expired / invalid / wrong app
{ "active": false }Reach for this over local verify on high-value mutations — move money, delete a user, change a billing address. Pay the round-trip to catch a session revoked after the token was issued: local verify keeps passing until exp; introspect flips to { "active": false } immediately.
No cross-token introspection. RFC 7662 allows a body token field that differs from the bearer; Auth accepts the field but requires it to match the Authorization header (a mismatch returns { "active": false }). This stops a stolen token being probed under the cover of another credential. To inspect a token, your code makes the call as that token — simplest form: send the token as the bearer with an empty JSON body.
6
/verify and /authorize — combined verify + permission check
One round-trip, one yes/no answer. Useful when your service doesn't want to manage JWKS or role resolution itself. Both read the token from the Authorization header, or from a body token field if you'd rather pass it explicitly.
# Signature, issuer, audience AND session all checked
{ "valid": true, "principal": { "sub": "...", "aid": "...", "role": "member", "type": "end_user" } }
# e.g. cryptographically valid but session revoked
{ "valid": false, "error": "TOKEN_REVOKED" }curl https://api.auth.productcraft.co/acme/v1/authorize \
-H 'authorization: Bearer <end-user-or-m2m-token>' \
-H 'content-type: application/json' \
-d '{ "permission": "invoice.refund" }'# Holds the permission
{ "authorized": true }
# Doesn't
{ "authorized": false, "error": "INSUFFICIENT_PERMISSIONS", "missing_permissions": ["invoice.refund"] }Or pass an array (every permission must be held):
# Body
{ "permissions": ["invoice.read", "invoice.refund"] }For checking many actions in one round-trip use POST /authorize/batch:
# Body
{ "checks": [
{ "id": "view-invoices", "permission": "invoice.read" },
{ "id": "refund-invoices", "permission": "invoice.refund" },
{ "id": "manage-team", "permissions": ["user.update", "role.assign"] }
] }
# Response
{ "results": [
{ "id": "view-invoices", "authorized": true, "missing_permissions": [] },
{ "id": "refund-invoices", "authorized": false, "missing_permissions": ["invoice.refund"] },
{ "id": "manage-team", "authorized": true, "missing_permissions": [] }
] }Use the batch shape when rendering a UI: one round-trip tells you which buttons to enable and which to disable. Each result reports the missing permission(s) so the UI can show a tooltip like “needs invoice.refund.”
7
Picking the right shape
- Hot path / API server / per-request gating: local JWKS +
/me/permissions(cached 60s). No Auth round-trip on most requests. - High-value mutation, revocation matters: local JWKS for the signature, then introspect for the live status. Or just introspect (one call, both checks).
- UI permission gating across many actions:
/authorize/batchat page load, render with the results. - A service that doesn't want to manage JWKS caching:
/verifyon every request. Slow but works.
The fastest production setup is local-JWKS for verification + /me/permissions with a 60s cache for permission resolution. Zero round-trips on a cache hit; one per minute per (token, route) pair in steady state.
8
Caveats
- Local verify cannot catch role demotion. The JWT still names the user's old role until it expires (1h default). Per-request
/me/permissionscovers this because it re-resolves the role's permission set — but the role name itself stays stale on the wire. For instant effect, revoke the user's sessions when demoting. - Local verify cannot catch session revocation. Use introspect or check the session list (
GET /me/sessions) if revocation latency matters for that route. - Don't verify expired tokens. JWT libraries throw on expired tokens by default — let them. Don't catch and re-validate downstream.