---
layout: tutorial
title: Validate access tokens
description: Verify incoming access tokens against your project's JWKS and read their scopes.
step: 4
---

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](https://github.com/panva/jose) handles the JWT verification and the JWKS fetching. Install it in the provider:

```sh
cd provider
pnpm add jose
```

# The token guard

Create `provider/src/lib/resource-server.ts`:

```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](/docs/products/auth/oauth-server/tokens#introspect) 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](/docs/products/auth/oauth-server/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](/docs/products/auth/oauth-server/tokens)) 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:

- `scope` holds the granted scopes as a single space-separated string, such as `openid profile email tasks.read`. `hasScope` splits it and looks for an exact entry.
- `sub` is 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.

**Build the JWKS URL from your own configuration**

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](/docs/products/auth/oauth-server/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.
