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
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:
- Reads the bearer token off the request.
- Verifies its signature against your environment's JWKS.
- Reads the claims and attaches a caller identity to the request.
- Distinguishes "this token is bad" from "I cannot check right now," which are different HTTP responses.
- 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.
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:
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:
Two details in that snippet that are easy to get wrong:
-
getJWKS()returnsundefinedwhen the client ID is unset, rather than throwing. If you skip the check, the failure surfaces later as a confusing error fromjwtVerifyabout 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 freshcreateRemoteJWKSetper 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:
Templates live under Authentication in the WorkOS dashboard. Then require that exact value, alongside the signature, the issuer, and the expiry:
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
audidentifying the application. Worth knowing before you turn it on, since anaudappearing 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
audnatively, 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:
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:
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:
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:
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:
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
Mutexso that concurrent callers await one result. - Some refresh failures are terminal and some are not. A
400 invalid_grantmeans 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.
sidfrom the token is whatrevokeSessiontakes, 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, andjtiare 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.