In this article
August 28, 2026
August 28, 2026

How to verify WorkOS access tokens in your own API

Using an Android client and a Node API, including the JWKS failure mode that quietly returns the wrong status code

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Signing a user in produces an access token. On its own that token does nothing: the value only appears when your API reads it, trusts it, and decides who the caller is. This tutorial covers that half of the flow, picking up where Add sign-in to an Android app with the WorkOS Android SDK leaves off.

Android is the client here and Node is the server, but only the two client-side snippets are Android-specific and they are labeled where they appear. Everything from step 2 onward applies to any client that sends a bearer token, whether that is iOS, a single-page app, or a CLI. The server examples use jose, since that is the library WorkOS documents, and the shape is the same in any language: fetch a key set, verify a signature, read claims.

One thing to know before you start, because it sets expectations for the whole tutorial. WorkOS server SDKs have helpers for verifying a sealed session cookie, which is what a server-rendered web app holds. They do not have a helper for verifying a bearer token, which is what a mobile app sends. So the verification step here is jose called directly, with the SDK supplying the key set. That is the documented approach, not a workaround.

What you will build

A middleware that:

  1. Reads the bearer token off the request.
  2. Verifies its signature against your environment's JWKS.
  3. Reads the claims and attaches a caller identity to the request.
  4. Distinguishes "this token is bad" from "I cannot check right now," which are different HTTP responses.
  5. Tells an expired client to refresh, rather than dumping the user back at sign-in.

Before you start

You need a working sign-in flow producing access tokens, the client ID for the environment, and Node 20.19.0 or later if you plan to require() anything, since jose is ESM-only.

  
npm install @workos-inc/node jose
  

You do not need the JWKS URL by hand, but it is worth knowing what it is: https://api.workos.com/sso/jwks/{clientId}. The public keys behind it are public, so nothing here requires a secret. Your API key stays out of this path entirely.

Note that the Sessions guide currently writes this URL with an http:// scheme. That is a typo in the docs. Use https.

Step 1: Send the token from your Android app

This step is Android-specific. If your client is something else, all it has to do is set Authorization: Bearer <access token>, and you can skip ahead to step 2.

An OkHttp interceptor attaches the token once, so it stays out of every call site:

  
class AuthInterceptor(
    private val sessionStore: SessionStore,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = sessionStore.accessToken
            ?: return chain.proceed(chain.request())

        val authorized = chain.request().newBuilder()
            .header("Authorization", "Bearer $token")
            .build()

        return chain.proceed(authorized)
    }
}
  

Send the access token, never the refresh token. The refresh token is the long-lived credential and your API has no use for it. An API that accepts refresh tokens is an API that can mint sessions, which is not a power a product backend should hold.

Step 2: Verify the signature

getJWKS() on the SDK gives you a remote key set with caching already configured, so you are not fetching keys on every request:

  
import { WorkOS } from '@workos-inc/node';
import { jwtVerify } from 'jose';

const workos = new WorkOS(process.env.WORKOS_API_KEY, {
  clientId: process.env.WORKOS_CLIENT_ID,
});

// The audience your environment's tokens are issued for.
const AUDIENCE = process.env.WORKOS_TOKEN_AUDIENCE;

async function verifyAccessToken(token) {
  const jwks = await workos.userManagement.getJWKS();

  if (!jwks) {
    // getJWKS returns undefined rather than throwing when clientId is missing.
    throw new Error('WorkOS clientId is not configured');
  }

  const { payload } = await jwtVerify(token, jwks, { audience: AUDIENCE });
  return payload;
}
  

Two details in that snippet that are easy to get wrong:

  • getJWKS() returns undefined when the client ID is unset, rather than throwing. If you skip the check, the failure surfaces later as a confusing error from jwtVerify about an invalid key input. Check it once at startup and fail loudly there instead.
  • The key set memoises internally with a five minute cooldown, so call getJWKS() per request without worrying. What you should not do is build a fresh createRemoteJWKSet per request, which throws away the cache and turns every API call into an outbound HTTP request.

Give the token an audience, then require it

AuthKit session access tokens do not carry an aud claim by default, and there is a reason for that. aud names the resource server a token authorizes access to, and a plain session token is not doing that job: it represents a first-party session in your own app, identified by client_id and validated against that app's key set.

The moment you send that token to your own API, though, that reasoning stops applying. You now have a resource server, and it should be named. So add an audience and require it.

Set it with a JWT template, using your API's URI as the value. aud is not on the reserved list, which is iss, sub, exp, iat, nbf, and jti, so a template is free to set it:

  
{
  "aud": "https://api.example.com"
}
  

Templates live under Authentication in the WorkOS dashboard. Then require that exact value, alongside the signature, the issuer, and the expiry:

  
const { payload } = await jwtVerify(token, jwks, {
  audience: 'https://api.example.com',
  issuer: process.env.WORKOS_TOKEN_ISSUER,
  clockTolerance: 5,
});
  

Four checks, three of which are explicit above. The signature comes from passing jwks, and exp is verified by jwtVerify automatically, which is what clockTolerance softens: a few seconds of slack absorbs the clock skew that otherwise rejects freshly minted tokens on a server that has drifted.

Read the issuer from configuration rather than hardcoding it. iss changes if the environment uses a custom auth domain, and it currently appears in WorkOS's own docs both with and without a trailing slash, so copy the exact value from a decoded token rather than retyping it from a doc page.

One audience per API

If your tokens reach more than one API, give each its own audience rather than one broad value shared between them. A token that every service accepts means a compromise anywhere is a compromise everywhere, and it takes away your ability to scope a token to the surface it was actually issued for.

Two related cases where aud shows up without you adding it:

  • Multiple applications. With multiple applications enabled, tokens carry an aud identifying the application. Worth knowing before you turn it on, since an aud appearing where your code was not expecting one changes what your validation sees.
  • OAuth and MCP access tokens. These authorize a resource server by design, so they carry aud natively, set from the requested resource or defaulting to the environment's client ID.

Not to be confused with either: the API Gateway page also tells you to verify aud, and it is right, but it describes the assertion in the X-WorkOS-Gateway-Assertion header, which has its own per-environment key set at https://<your-gateway-domain>/.well-known/jwks.json, its own issuer, and an audience configured on the gateway.

Step 3: Read the claims

A verified token carries:

Claim Type What it is
sub string The WorkOS user ID, and the only claim you should key your own records on
sid string The session ID, needed to sign the user out or revoke the session
org_id string The organization selected at sign-in, when the session is scoped to one
role string The role on that organization membership
roles array All roles on the membership
permissions string[] Permissions granted by the role
entitlements string[] Entitlements on the membership
feature_flags string[] Feature flags for the organization
client_id string The client that issued the token
act object Present during impersonation, naming the acting admin
jti string Token ID
exp, iat number Standard expiry and issued-at

Authorize on permissions, not on role. Roles get renamed and re-scoped by customer admins, and code that branches on the string "admin" breaks silently when someone creates "Admin". Permissions are the stable contract.

If you are on TypeScript, the SDK exports UserManagementAccessToken, but be aware it only types the AuthKit-specific claims. It has no sub, exp, or iss, so intersect it with jose's payload type:

  
import type { JWTPayload } from 'jose';
import type { UserManagementAccessToken } from '@workos-inc/node';

type AccessTokenClaims = JWTPayload & UserManagementAccessToken;
  

Typing a decoded payload as UserManagementAccessToken alone means payload.sub fails to compile, which is a confusing ten minutes if you do not know why.

Step 4: Handle the failure modes correctly

This is the step most guides skip, and the one that produces the strangest bugs.

jose throws distinct error classes, each carrying a stable code. Group them by what the caller should do about it:

Error code Meaning Response
JWTExpired ERR_JWT_EXPIRED Valid token, past its expiry 401, refreshable
JWSSignatureVerificationFailed ERR_JWS_SIGNATURE_VERIFICATION_FAILED Forged or corrupted 401
JWTInvalid ERR_JWT_INVALID Not a well-formed JWT 401
JWKSNoMatchingKey ERR_JWKS_NO_MATCHING_KEY No key in the set matches the token's kid 401
JWKSTimeout ERR_JWKS_TIMEOUT Could not fetch the key set 503

The last two rows are the trap. Notice their codes begin ERR_JWKS_, not ERR_JWT_ or ERR_JWS_. If you copy the classification the WorkOS SDK uses internally for cookie sessions, which treats a token as invalid only when the code starts with ERR_JWT_ or ERR_JWS_ and rethrows everything else, then a JWKS timeout escapes your handler as an unhandled exception. Your monitoring shows a 500 spike, and the actual cause is that api.workos.com was briefly slow.

ERR_JWKS_TIMEOUT is a 503 with a Retry-After, because the problem is yours, not the caller's. Returning 401 there tells every signed-in user their session is invalid over a transient network blip, and a client that dutifully signs the user out on 401 will log out your entire active user base.

Also worth knowing: JWTExpired does not extend JWTClaimValidationFailed in jose, so an instanceof JWTClaimValidationFailed check does not catch expiry. Match on code:

  
export async function requireUser(req, res, next) {
  const header = req.headers.authorization ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  if (!token) {
    return res.status(401).json({ error: 'missing_token' });
  }

  try {
    const claims = await verifyAccessToken(token);

    req.caller = {
      userId: claims.sub,
      sessionId: claims.sid,
      organizationId: claims.org_id,
      permissions: claims.permissions ?? [],
      impersonated: Boolean(claims.act),
    };

    return next();
  } catch (error) {
    const code = error?.code;

    if (code === 'ERR_JWT_EXPIRED') {
      // Distinguishable so the client refreshes instead of signing out.
      return res.status(401).json({ error: 'token_expired' });
    }

    if (code === 'ERR_JWKS_TIMEOUT') {
      res.set('Retry-After', '5');
      return res.status(503).json({ error: 'verification_unavailable' });
    }

    if (typeof code === 'string' && code.startsWith('ERR_')) {
      return res.status(401).json({ error: 'invalid_token' });
    }

    return next(error);
  }
}
  

Never put the token, or any part of it, in the error you return or the line you log. A token in a log aggregator is a token available to everyone with read access to your logs.

Step 5: Make expiry recoverable

Access tokens are short lived by design, and the duration is configurable per application in the dashboard under Sessions. Keep it short: a shorter access token means a revoked session or a changed role takes effect sooner, and the refresh path is what makes that cheap.

The consequence is that expiry is a normal event, not an error. Your API should say which kind of 401 it is, as the middleware above does with token_expired, so the client can tell "refresh and retry" from "sign in again."

Back on the Android side, an OkHttp Authenticator handles the retry without touching call sites. This is the second and last Android-specific snippet:

  
class TokenRefreshAuthenticator(
    private val sessionStore: SessionStore,
    private val refresher: TokenRefresher,
) : Authenticator {

    override fun authenticate(route: Route?, response: Response): Request? {
        // Give up rather than loop if the retry also came back 401.
        if (response.priorResponse != null) return null

        val refreshed = runBlocking { refresher.refresh() } ?: return null

        return response.request.newBuilder()
            .header("Authorization", "Bearer ${refreshed.accessToken}")
            .build()
    }
}
  

Two things this depends on:

  • The refresh has to be serialized. Several requests failing at once will each try to refresh, and because refresh tokens rotate, the ones that lose the race present a retired token. WorkOS allows a 30 second grace period in which replaying a refresh returns the same rotated pair, which saves you from the narrowest version of this race, but not from a wider one. Guard the refresh with a Mutex so that concurrent callers await one result.
  • Some refresh failures are terminal and some are not. A 400 invalid_grant means the refresh token is dead and the user has to sign in again. A network error, a 429, or a 5xx means try again shortly. Treating a transient failure as terminal signs people out for no reason. The session resilience guide covers the full decision table.

Step 6: Map claims to your own records

Key your user records on sub, the WorkOS user ID. It is stable for the life of the user.

Do not key on email. Emails change, and an email as a primary key means a user who updates their address either loses their history or, worse, inherits somebody else's. Store the email as an attribute you refresh from the token, and treat sub as the identity.

For a multi-tenant app, org_id tells you which tenant the caller is acting as. A user can belong to several organizations, so the same sub can arrive with different org_id values in different sessions. Scope your queries with both, and never infer the tenant from anything the client sends in a body or a path parameter, which is how cross-tenant data leaks happen.

If act is present, the caller is an admin impersonating the user. Consider whether writes should be permitted at all in that mode, and if they are, record the acting admin in your audit trail rather than attributing the action to the user.

Where to go from here

  • Ending a session server-side. sid from the token is what revokeSession takes, which is how you build "sign out all devices" or cut off a compromised session without waiting for expiry.
  • Custom claims. JWT templates add your own claims to the token, which can save a database lookup per request. iss, sub, exp, iat, nbf, and jti are reserved, and the rendered template is capped at 3072 bytes.
  • API Gateway. If you would rather not run this middleware at all, AuthKit API Gateway authenticates the caller in front of your origin and forwards a short-lived signed assertion. It is in beta.

For the reference material behind this tutorial, see session tokens for the full claim list and sessions for the configuration options.