Validate access tokens_
Verify incoming access tokens against your project's JWKS and read their scopes.
2 min read
TaskFlow's API is about to accept access tokens from the outside world, so it first needs a way to tell a token it issued from one somebody made up. Access tokens from your OAuth2 server are RS256-signed JWTs, and the matching public keys are published at your project's JWKS endpoint. That means TaskFlow can verify tokens locally, with no call back to the OAuth2 server on each request.
Install jose
jose handles the JWT verification and the JWKS fetching. Install it in the provider:
cd providerpnpm add joseThe token guard
Create provider/src/lib/resource-server.ts:
// provider/src/lib/resource-server.ts// TaskFlow's resource server: validates access tokens issued by the// project's OAuth2 server and enforces the scopes they carry.import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
const issuer = process.env.OAUTH_ISSUER!
// The OAuth2 server publishes its signing keys as a JWK Set. jose caches// the keys and refetches them when it sees an unknown key ID.const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`))
export type AccessToken = JWTPayload & { scope?: string client_id: string}
/** Verify a Bearer token's signature against the JWKS and return its * claims. Throws if the token is missing, expired, not ours, or not an * access token. Requiring the at+jwt type (RFC 9068) is what stops an * ID token, which is signed with the same key, from being accepted here. */export async function verifyAccessToken( authorization: string | null,): Promise<AccessToken> { const token = authorization?.match(/^Bearer (.+)$/)?.[1] if (!token) throw new Error('missing_token') const { payload } = await jwtVerify(token, jwks, { typ: 'at+jwt' }) return payload as AccessToken}
/** Check that the token was granted a scope. Scopes are a * space-separated string in the token's scope claim. */export function hasScope(token: AccessToken, scope: string): boolean { return (token.scope ?? '').split(' ').includes(scope)}jwtVerify checks the signature against the published keys and rejects expired tokens, so a forged token never reaches your handlers. It also pins the algorithm to the RS256 keys in the JWKS, so alg: none and HMAC confusion attacks fail here too.
Two limits of this guard are worth knowing.
Offline verification reads the token and nothing else, so it cannot see that a token was revoked or that its refresh rotated. A revoked token keeps passing this check until it expires, which for a confidential client is up to eight hours. Where that window is too long, call introspection instead, which checks the token against its stored family.
The guard also accepts any access token this project issued, because it checks the signature and type but not aud. That is enough here, since TaskFlow's OAuth2 server is the only issuer whose keys the JWKS publishes. An API that should only accept tokens minted for itself asks the client to send a resource indicator on the authorization request, then checks that value in aud.
The { typ: 'at+jwt' } option matters more than it looks. Your OAuth2 server signs ID tokens with the same key as access tokens, and an ID token has no scope claim. Without the type check, a client could present its ID token to your API; requiring at+jwt (the access token type) rejects it before it reaches a handler. An ID token is meant for the client that received it, never as a credential for your API.
Two claims in the verified payload matter for the API:
scopeholds the granted scopes as a single space-separated string, such asopenid profile email tasks.read.hasScopesplits it and looks for an exact entry.subis the ID of the user who approved the grant. The API uses it to load that user's tasks, so a token can never read anyone else's data.
The guard builds the JWKS URL from the OAUTH_ISSUER value the provider already has, the same base it uses for every other OAuth2 server call. Since tokens are standard JWTs, any JWT library with JWKS support in any language can do this job. The API route just happens to live next to the consent screen here.
Continue to put the guard in front of an API route.
Was this page helpful?
Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.