Short answer: use JWKS verification to establish that a token was signed by an accepted issuer, then use session verification to decide whether that still-valid token should be allowed to act. For a fintech API rotating refresh tokens after a stolen session, the second check owns the recovery decision. Signature validity alone cannot revoke a live session.
That boundary sounds tidy until a customer calls after losing a phone. The support flow may revoke one device, all devices, or every session except a newly verified recovery session. Your API needs to make those choices visible in code and in telemetry, not hide them inside a generic “invalid token” response.
The decision table: which check owns which risk?
| Check | Trust established | Best use | What it cannot answer |
|---|---|---|---|
| JWKS signature and claims | The issuer signed this JWT, and its issuer, audience, expiry, and key id are acceptable | Stateless access-token verification at an API edge | Whether a user or device was revoked five seconds ago |
| Session record | This session id is active, belongs to the subject, and has the current policy version | Revocation, refresh-token rotation, device recovery, and incident response | Whether the JWT was forged if you never validate its signature |
| Both, in order | The request is cryptographically authentic and operationally allowed | High-value writes such as payouts, beneficiary changes, and token rotation | A recovery policy you have not defined |
Pick JWKS first when the endpoint needs low-latency reads and a short access-token lifetime is an acceptable revocation delay. Pick a session lookup when a support agent must kill one device immediately, or when a refresh token is a one-time credential. Pick both for a refresh endpoint: verify the JWT, then atomically consume the session’s refresh-token family.
One sentence is enough here: a valid signature is not a valid recovery decision.
How should JWKS and session verification split trust for API requests?
Think of the request as crossing two doors. The JWKS door asks, “Did a trusted issuer sign these bytes for this audience?” The session door asks, “Given this subject, session id, and policy version, may this action happen now?” Keeping those questions separate makes failures diagnosable.
For a fintech refresh flow, the access token can carry sub, sid, iss, aud, exp, and jti. The API validates the signature and registered claims against a cached JWKS set. It then loads the session keyed by sid, checks that the session is active, and compares the token’s sessionVersion with the server value. A stolen token fails at the second door after revocation, even if its exp claim is still in the future.
The order matters. Do not query a session store for an unsigned token; that turns attacker-controlled identifiers into database work. Do not skip the session check for a refresh endpoint; rotation is precisely where replay becomes a recovery event. Return the same external status for an unknown session and a revoked session, while logging an internal reason code such as session_revoked or session_version_mismatch.
Here is the smallest version of that boundary. The interfaces are deliberately generic so the policy can live in a service, a database, or a cache.
type Claims = {
sub: string;
sid: string;
iss: string;
aud: string;
exp: number;
jti: string;
sessionVersion: number;
};
type Session = {
userId: string;
active: boolean;
version: number;
refreshJti: string;
};
interface SessionStore {
get(id: string): Promise<Session | null>;
rotate(id: string, oldJti: string, nextJti: string): Promise<boolean>;
}
async function authorizeRefresh(
rawToken: string,
verifyJwt: (token: string) => Promise<Claims>,
sessions: SessionStore,
nextJti: string,
) {
const claims = await verifyJwt(rawToken); // JWKS + iss/aud/exp checks
const session = await sessions.get(claims.sid);
if (!session || !session.active || session.userId !== claims.sub) {
throw new Error("session_not_active");
}
if (session.version !== claims.sessionVersion) {
throw new Error("session_version_mismatch");
}
if (session.refreshJti !== claims.jti) {
throw new Error("refresh_reuse_detected");
}
const rotated = await sessions.rotate(claims.sid, claims.jti, nextJti);
if (!rotated) throw new Error("refresh_reuse_detected");
return { userId: session.userId, sessionId: claims.sid, refreshJti: nextJti };
}
The compare-and-swap inside rotate is the important part. Two concurrent requests must not both receive a new refresh token. A failed compare is an event to alert on, not a reason to retry blindly.
What does recovery look like after a stolen session?
Start with a recovery state machine, not a pile of deletes. A support-approved recovery can move one session from active to revoked, increment the user’s sessionVersion, or create a new session after step-up authentication. Each transition gets an actor, reason, timestamp, and correlation id. The refresh endpoint reads the same state, so revocation takes effect without waiting for an access token to expire.
For observability, emit a structured event at each trust boundary. Useful fields include request_id, sub_hash, sid_hash, jti_hash, issuer, kid, decision (allow or deny), reason, and verification latency. Hash identifiers before sending them to shared logs. Never log the raw token, refresh token, or recovery code.
Metrics should separate cryptographic failures from policy failures. A rising jwt_signature_invalid rate can indicate key rollover or an attack. A rising session_revoked rate can indicate a support campaign or a stolen-device incident. If both are flattened into HTTP 401 counts, the on-call engineer gets noise instead of a recovery signal.
Alert on patterns, not one rejected request: repeated refresh reuse for one sid, a sudden change in kid, or many revocations for one account. Your runbook should say who can revoke all sessions, how a customer proves control of the account, and how the newly issued session is recorded. I’m not sure any single threshold works across every fraud model; tune it against your normal login and recovery volume.
Where do common implementations fail?
The first failure is treating JWKS cache freshness as revocation. Key rotation changes which signatures are trusted; it does not mark a device stolen. Keep issuer metadata and session policy in separate caches with separate expiry and invalidation paths.
The second is making sid optional on refresh tokens. Without a stable session handle, “revoke this phone” becomes “revoke this user,” which is a poor recovery experience and a tempting support shortcut. Require the claim for flows that promise per-device recovery, and reject tokens from older formats during a planned migration.
The third is retrying a failed rotation. A timeout after the store committed is ambiguous. Read the session by id and compare the presented jti before deciding whether to issue anything. A retry that skips that check can turn one stolen refresh token into two valid sessions.
Here is the race I would put in a runbook example. A customer reports a stolen phone at 09:14, and support revokes session s-842 while the mobile app sends a refresh request already in flight. The request has a perfectly valid signature, an unexpired exp, and the old jti; none of those facts should win the race. The revocation transaction marks s-842 inactive and increments its version. The refresh handler then reads the session, sees either active=false or a version mismatch, emits session_revoked, and returns the same generic denial it uses for an unknown session. If the order flips and refresh commits first, the support action must revoke the newly rotated family as part of the same recovery command. That is why the state transition, audit event, and alert correlation id belong in one operation rather than in three loosely coupled queues. The exact lock or transaction primitive depends on your datastore, but the invariant does not: after recovery completes, no token from that session family can mint another session.
The fourth is testing only the happy path. Include expired keys, an issuer mismatch, a wrong audience, a revoked session, a version mismatch, duplicate rotation, and a support recovery racing with refresh. Record the decision reason in test output. That makes a production trace explainable later.
Limits and a practical choice
This design adds a session-store dependency to refresh and other sensitive writes. That is a real trade-off. If your service must keep accepting requests during a session-store outage, use short-lived access tokens and document the revocation window; do not pretend a cached JWKS check provides instant recovery.
It is also not suitable when your clients cannot carry a stable session identifier, or when your organization has no staffed recovery path for high-risk account changes. In those cases, keep the policy simpler and choose a shorter token lifetime, stronger step-up authentication, or a gateway that can enforce a shared session registry. Stick with signature-only verification for genuinely public, read-only data where delayed revocation is an explicit requirement.
The useful boundary is the one your incident team can operate: JWKS answers “authentic,” session state answers “allowed now,” and recovery telemetry explains why the answer changed.
Top comments (0)