Token verification
Everything on the previous pages decides what to render. This decides what is allowed, and it is the only part that protects anything.
A client holds a token and sends it. The service receiving it must not take it on trust: signature, issuer, audience and expiry, on every request.
import { createTokenVerifier } from '@fluixi/oauth2/verify';
const verify = createTokenVerifier({
issuer: process.env.OIDC_ISSUER!,
audience: 'admin-spa',
});
const claims = await verify.fromRequest(request); // throws unless everything holds
Nothing here is Fluixi-specific. It takes a Request or a bare string and returns claims,
so an Express service, a worker, a Hono route or a plain Node handler can import it without
adopting anything else.
In an API route
// src/api/orders.ts
import { createTokenVerifier, TokenError } from '@fluixi/oauth2/verify';
const verify = createTokenVerifier({ issuer, audience });
export async function GET(request: Request) {
try {
const claims = await verify.fromRequest(request);
return Response.json(await listOrdersFor(claims.sub));
} catch (cause) {
const code = cause instanceof TokenError ? cause.code : 'invalid_token';
return new Response(JSON.stringify({ error: code }), {
status: 401,
headers: { 'www-authenticate': `Bearer error="${code}"` },
});
}
}
The WWW-Authenticate header is worth sending. It is how a client tells an expired token
from a broken endpoint, and what @fluixi/oauth2's own client reads to decide it should
refresh.
The API guard
Repeating that in every route is how one of them ends up without the header.
createApiGuard wraps a handler instead, and it ships with the package, so the header format
and the client that reads it come from the same place:
// src/services/verify.server.ts
import { createTokenVerifier, createApiGuard } from '@fluixi/oauth2/verify';
import { rolesFrom } from '@fluixi/oauth2/roles';
import { auth } from '../config/env.js';
// The preset knows what this provider's tokens look like: issuer spelling, audience policy.
export const verify = createTokenVerifier(auth.provider.verifier(auth.clientId));
export const guard = createApiGuard({ verify, roles: rolesFrom.keycloakRealm });
// src/api/orders.ts
import { guard } from '../services/verify.server.js';
export const GET = guard(async (_request, claims) => Response.json(await listOrdersFor(String(claims.sub))));
export const DELETE = guard(deleteOrder, { role: 'admin' });
export const POST = guard(deleteOrder, {
scope: 'orders:write',
require: (claims) => claims.tenant === 'acme' || 'wrong tenant',
});
A token that fails gets a 401 with WWW-Authenticate: Bearer error="<code>". A genuine token
that is not enough for the route gets a 403. Keep the two apart: a client that sees 401 for a
missing role refreshes a perfectly good token and asks again, forever. The 401 carries the error
code and never the message, because a refusal that explains why a token failed tells an
attacker which guesses are close.
auth.provider.verifier(auth.clientId) builds the verifier options from the preset
authFromEnv chose (see OAuth2), so the issuer spelling and audience policy
match the provider.
The second argument is what the route demands beyond a valid token:
| Requirement | Passes when |
|---|---|
role |
the token holds any one of the roles |
allRoles |
it holds every one |
scope |
its scope claim holds any one of them |
require(claims, request) |
the function returns true; a string refuses and says why |
Providers keep roles in different places, so there is no default. A role requirement on a
guard built without roles throws, rather than answering 403 to everyone and looking like a
permissions problem. rolesFrom covers the shapes in use:
rolesFrom. |
Reads |
|---|---|
keycloakRealm |
realm_access.roles |
keycloakClient(clientId) |
resource_access[clientId].roles |
groups |
groups, as Okta sends it when a groups claim is configured |
cognito |
cognito:groups |
namespaced(claim) |
a custom claim, as an Auth0 action adds it |
scope |
the scope claim, for a provider that issues no roles |
onRefused(refusal, request) answers in the app's own error shape. A 401 still gets the
WWW-Authenticate header unless the response already carries one, because without it the
client cannot tell an expired token from a dead endpoint.
Why the options are shaped this way
The cryptography is jose's, deliberately. A
hand-written verifier is how alg: none and RS256-to-HS256 confusion get shipped, and
both turn a check into a way in. What this adds is the configuration around it, where the
common mistakes live.
issuer is required and never taken from the token. A token naming its own issuer
proves nothing.
audience is required, or an explicit audience: false. Forgetting it is easy and
silently accepts a token minted for any other client of the same provider.
algorithms defaults to ['RS256'] and is never read from the token header. That is
what stops a token switching to HS256 and being signed with the public key as an HMAC
secret. none is rejected when the verifier is built.
The JWKS comes from discovery, so a service does not hardcode the URL, and jose
handles caching, key rotation and refetch cooldown. Give jwksUri to skip discovery, or
jwks to supply the keys directly, which pins them and stops rotation working.
jose is an optional peer dependency. An app that never verifies never installs it.
Providers that omit aud
Keycloak issues access tokens with no aud claim until an audience mapper is configured,
naming the client in azp instead. A service in front of one wants:
createTokenVerifier({
issuer,
audience: false,
authorizedParty: 'admin-spa',
});
Not audience: false on its own, which accepts a token minted for any client of the
issuer. That skips the check rather than moving it, and the verifier warns when neither is
set.
Configuring an audience mapper at the provider and using audience is the better end
state. authorizedParty is for when you do not control that.
A provider that names itself more than one way
Google publishes its issuer as both accounts.google.com and
https://accounts.google.com, and a token can carry either. A service pinned to one
rejects the other, which looks like a signature failure and is not.
issuer takes a list for that. Discovery uses the first; any of them is accepted on a
token.
createTokenVerifier({
issuer: ['https://accounts.google.com', 'accounts.google.com'],
audience: clientId,
});
Extra checks
require runs once the token holds, for anything specific to the service. Return true,
or a string to use as the message.
const verify = createTokenVerifier({
issuer,
audience,
require: (claims) =>
String(claims.scope ?? '').includes('billing') || 'This endpoint needs the billing scope.',
});
DPoP-bound tokens
A token bound with DPoP carries cnf.jkt, the thumbprint of the client's key, and each request
carries a proof signed with that key in the DPoP header. Without dpop: true the verifier
accepts a bound token as though it were a bearer token, and the binding protects nothing.
import { createTokenVerifier } from '@fluixi/oauth2/verify';
export const verify = createTokenVerifier({
issuer: 'https://example.okta.com/oauth2/default',
audience: 'api://orders',
dpop: true,
});
export async function claimsOf(request: Request) {
// `fromRequest`, not `verify(token)`: the proof is a header on the request.
return verify.fromRequest(request);
}
With it, a token without cnf.jkt is refused, and so is a bound one whose proof does not match:
the proof's key must have the token's thumbprint, htm and htu must match this request's
method and URL, ath must be this token's hash, and iat must be within dpopProofAgeSec
(10 seconds unless set). A missing proof is missing_dpop_proof and any mismatch
invalid_dpop_proof; createApiGuard returns either as the 401 code. The client side, in both
token modes, is on the OAuth2 page.
Opaque tokens, and introspection
Not every provider issues a JWT. An opaque reference token carries nothing and means nothing outside the provider, which is the point: it leaks nothing if it is intercepted, and the provider stays the only thing that can say what it stands for.
There is no way to verify one locally. The service asks instead, over RFC 7662:
import { createTokenIntrospector } from '@fluixi/oauth2/verify';
const verify = createTokenIntrospector({
issuer: process.env.OIDC_ISSUER!,
clientId: 'orders-api',
clientSecret: process.env.ORDERS_API_SECRET!,
audience: 'orders-api',
});
const claims = await verify.fromRequest(request); // same shape as the JWT verifier
It returns the same TokenVerifier, so guard above works unchanged. Swapping one for
the other is a one-line change.
What it costs and what it buys
Introspection is a network call to the provider on the path of every request, where JWT verification is local arithmetic against a cached key.
What you get for that is the property a signature cannot give. A signature says a token was genuine when it was minted; introspection says it is good now. A token revoked five minutes ago still verifies locally and is refused here at once.
cacheMs reuses an active answer for a window, and defaults to 0, meaning ask every
time. Anything above zero trades away exactly the property you came for, so keep it small
and deliberate. A refusal is never cached, because that would keep a reinstated token out.
Client credentials are required
The introspection endpoint is authenticated, and that is not incidental: an open one would
let anyone test whether a stolen token is still live. A service needs its own clientId
and clientSecret at the provider, so this is a server-side tool. Never ship these to a
browser.
Credentials go in an Authorization: Basic header by default. clientAuthMethod: 'body'
puts them in the form for a provider that wants them there.
Credentials from the environment
How a client authenticates to introspection is the provider's choice, made in its console: a
shared secret, or private_key_jwt, a signature from a key whose public half the provider
holds. The two are exclusive, and code that assumes a secret has nothing to read for an Okta
client set to public/private key. credentialsFromEnv reads whichever is configured:
// src/api/introspect.ts
import { createTokenIntrospector, credentialsFromEnv, explainCredentials } from '@fluixi/oauth2/verify';
export async function POST(request: Request) {
const credentials = await credentialsFromEnv({ prefix: 'okta' });
if (!credentials) {
return Response.json({ error: explainCredentials({ prefix: 'okta' }) }, { status: 501 });
}
const introspect = createTokenIntrospector({
issuer: 'https://example.okta.com/oauth2/default',
...credentials,
audience: false,
});
return Response.json(await introspect.fromRequest(request));
}
| Variable | Meaning |
|---|---|
OKTA_CLIENT_ID |
the client that asks; clientId in the options otherwise |
OKTA_CLIENT_SECRET or OKTA_SECRET |
a shared secret |
OKTA_PRIVATE_KEY |
path to the private JWK; ~ is expanded |
OKTA_KEY_ID |
the kid the provider knows the key by |
OKTA_ASSERTION_ALG |
the assertion algorithm, when not the default |
The prefix is yours; okta above. <PREFIX>_INTROSPECT_* is tried first, for a provider that
wants a different client to ask than the one people sign in with: a public SPA at Okta cannot
introspect, so an API Services client does it. A key wins over a secret. A key file that is
named but unreadable throws, because that is a broken configuration rather than a missing one,
and null means nothing is configured. explainCredentials names both ways to set it up, which
is what the 501 above tells an operator. Server only: the key file is read with Node.
The active: false that means something else
A provider answers { "active": false } and nothing more, so a genuinely dead token and a
misconfiguration look identical.
The usual cause of the second is that the asking client is not in the token's audience.
Keycloak refuses to introspect a token minted for someone else and says only active: false about it. The fix is an audience mapper on the client so its tokens carry
aud: <client>, after which introspection answers normally.
Which to use
createTokenVerifier |
createTokenIntrospector |
|
|---|---|---|
| Token | a JWT | opaque, or a JWT |
| Cost | local, one cached key fetch | a request to the provider, per call |
| Revocation | not seen until expiry | immediate |
| Needs client credentials | no | yes |
Use the verifier where tokens are JWTs and short-lived. Use the introspector where tokens are opaque, or where a revoked session must stop working at once and you will pay a round trip for it.
Verified and informative are different
A token can verify perfectly and tell you almost nothing. Keycloak's lightweight access
tokens carry no sub and few claims, and any provider can be configured to issue thin
tokens.
So verification answers "is this genuine and for me". It does not answer "who is this", and a service needing the second should call userinfo or its own store rather than assuming the claims are there.
Revocation is the other limit. A signature-valid token for an account disabled five minutes ago still verifies. Short token lifetimes and a check against your own store are what close that, not the signature.
What belongs where
| Question | Where |
|---|---|
| Should I show this button | the client, with can() and Can |
| Is this token genuine and for me | this page, at the API |
| Is this person allowed to do this | your API, after verifying |
The first is a rendering aid and a client can forge it. The second and third are the real boundary.
Next: Routing.