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. Heimdall 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 Heimdall 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.
Heimdall 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 { Heimdall, JwtExpiredError } from "@productcraft/heimdall";
const heimdall = new Heimdall();
const scope = heimdall.consumer("acme");
export async function verifyToken(bearer: string) {
try {
const claims = await scope.verifyToken(bearer);
return claims; // { sub, aid, role, type, org_id?, org_role?, exp, iat, iss }
} 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 heimdall.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 (Heimdall rotated a key) the library refreshes the JWKS automatically. The 7-day overlap window during rotation means you almost never see a hard failure.
import jwt
from jwt import PyJWKClient
JWKS_URL = 'https://api.heimdall.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'],
options={'verify_aud': False},
issuer='heimdall',
)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",
"org_id": "f9b3..." | undefined, // tenant when active
"org_role": "admin" | undefined,
"iat": 1778499565,
"exp": 1778503165, // iat + access_token_ttl
"iss": "heimdall"
}An M2M access token:
{
"sub": "m2m_a1b2c3...", // client_id
"aid": "53641fdb-...",
"type": "m2m",
"scopes": ["user.read", "invoice.read"],
"iat": ...,
"exp": ...,
"iss": "heimdall"
}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. Heimdall provides /me/permissions for this.
curl https://api.heimdall.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 Heimdall's 60s LRU — role-permission edits propagate within a minute.
Your service caches the response per token+session for a short window (60s mirrors Heimdall'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 Heimdall the live question.
curl https://api.heimdall.productcraft.co/acme/v1/oauth/introspect \
-H 'authorization: Bearer <the-token-you-want-to-introspect>'RFC 7662 shape:
# Valid token
{ "active": true, "sub": "...", "type": "end_user", "exp": ..., "aid": "...", "role": "member" }
# Revoked / expired / invalid / wrong app
{ "active": false }When to reach for this over local verify:
- High-value mutations. Move money, delete a user, change a billing address. Pay the round-trip to catch a token revoked in the last 0-5 minutes.
- Token introspection from a different lane. An M2M service that wants to inspect an EndUser's token without verifying it locally — pass the EndUser token in
Authorizationwith the M2M token presenting itself... wait. Read the next sentence.
The body token field is gone. Heimdall's introspect endpoint accepts only the Authorization header for the token being inspected. RFC 7662 allows a separate token in the body that could be different from the bearer; Heimdall refuses cross-token introspection (stolen-token-probes against another credential). If you need to inspect a token your service holds for someone else, your code is the one making the call as that token.
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.
curl https://api.heimdall.productcraft.co/acme/v1/authorize \
-H 'authorization: Bearer <end-user-or-m2m-token>' \
-H 'content-type: application/json' \
-d '{ "permission": "invoice.refund" }'{
"authorized": true,
"reason": null,
"principal": { "id": "...", "type": "end_user", "role": "admin" }
}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 },
{ "id": "refund-invoices", "authorized": false, "missing": ["invoice.refund"] },
{ "id": "manage-team", "authorized": true }
] }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 Heimdall 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.