Reject Bad Tokens in Your Own API

Add real signature verification to a Node API and return 503 on JWKS outages.

Introduction

30 Second Summary

Every app you sign into hands your requests a token. Your own API has to decide, on every single request, whether that token is real or just decoration.

In this project, you will put real signature checking in front of a Node API route that currently answers anyone who asks. Then you will break it on purpose to see the difference between a token that is wrong and a key server you cannot reach right now.

What You'll Build

You will watch one unchanged route answer four curl requests four different ways, and know exactly why each answer is the honest one.

By the end of this project, you'll have:

  • A protected route that reads your caller's subject and session id straight out of a verified token and hands them back in the response body.
  • Four terminal runs against that same route that come back 200, 401, 503, then 200 again, with an expired token refreshed in front of you.
  • A middleware file you can lift into your next API, because it tells a bad token apart from a key server that is simply unreachable.
  • Secret Mission: Cache the key set in memory with a short expiry so a brief outage serves from cache instead of answering 503.

Are there any prerequisites?

You need Node 20.19 or newer, curl, and a free WorkOS account.

The free tier covers everything you do here, so the whole project costs nothing to run.

Before We Start

An API that hands back data to whoever asks is not protected, it is decorated. Over this project you will put real signature checking in front of one route, then break it on purpose to watch how it fails.

None of that means anything without a genuine token to test against. In this step you get WorkOS to mint you one you can paste straight into a terminal.

In this step, get ready to:
  • Confirm you have Node.js 20.19.0 or newer installed.
  • Create a WorkOS account and copy your application's Client ID.
  • Sign in once through the hosted login and copy a real access token.
Check your Node.js version

The signature-checking library this project leans on, jose, ships as an ES module only. That pins you to Node 20.19.0 or newer.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Find out which version you already have by running this command:
node --version

✔️ I see v20.19.0 or higher

Node is ready and nothing needs installing. Head straight on to creating your WorkOS account.

ⓧ I see a version lower than v20.19.0

Your Node install predates the version jose needs. The official installer upgrades in place, so there is nothing to uninstall first.

  • Open the Node.js downloads page.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the defaults it offers.
  • Close your terminal window, then reopen it.
  • Check the version again by running this command:
node --version

You should now see v20.19.0 or higher.

ⓧ Command not found

Node.js is not on this machine yet. The official installer adds both node and npm in one go.

  • Open the Node.js downloads page.
  • Download the LTS installer for your operating system.
  • Run the installer and accept the defaults it offers.
  • Close your terminal window, then reopen it.
  • Check that Node is now on your path by running this command:
node --version

You should now see v20.19.0 or higher instead of an error.

Create your WorkOS account and copy your Client ID

Your API cannot check a signature without knowing whose keys to check it against. The Client ID is what names that key set.

  • Open the WorkOS Dashboard at dashboard.workos.com.
  • Create a free account, then follow the sign-up prompts until the dashboard loads.
  • Open the Applications section of the dashboard.
  • Open the first application in the list.
  • Copy the Client ID shown for that application into a scratch file.

That first application is the default one WorkOS generates for every environment. You did not have to create it.

Will any of this cost money?

WorkOS puts the first 1 million monthly active users on its free tier. One developer signing in a handful of times sits nowhere near that line.

Sign in through hosted AuthKit and copy your token

A Client ID on its own proves nothing about a caller. You need a token that a real sign-in produced.

Why the device flow instead of a login page?

The usual route to a token is building a web app, registering a redirect URI, and catching the callback. That is a whole afternoon of work before you have anything worth verifying.

The OAuth 2.0 device flow skips all of it. Your terminal asks for a code, you confirm that code in a browser, and the token lands back in the terminal.

  • Ask WorkOS for a device code by running this command, swapping in the Client ID you just copied:
curl -X POST https://api.workos.com/user_management/authorize/device \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'client_id=[[WORKOS_CLIENT_ID="your WorkOS Client ID"]]'

What comes back?

  • user_code is the short code you confirm in the browser.
  • verification_uri_complete is the link that opens the hosted login with that code already filled in.
  • device_code belongs to your terminal alone. It is what you trade for the token.
  • Check that the response contains a user_code and a verification_uri_complete value.

Got an error object instead of codes?

The usual cause is a Client ID that picked up a stray space or a truncated character on the way over. Copy it again from the Applications section and rerun the command.

Still stuck? Help me read this WorkOS error

  • Copy the verification_uri_complete value from your terminal.
  • Paste it into your browser's address bar.
  • Press Enter to load the page.
  • Sign up for an account on the AuthKit page that loads.
  • Check that the code on screen matches the user_code in your terminal.
  • Approve the code when the page asks you to confirm it.

The browser now has your session and your terminal does not. The last move is to claim it.

Before you run the next command, what do you think comes back: the token itself, or another code to confirm?

  • Replace your-device-code-here below with the device_code value from your first response.
  • Trade that device code for tokens by running this command:
curl -X POST https://api.workos.com/user_management/authenticate \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
  -d 'device_code=your-device-code-here' \
  -d 'client_id=[[WORKOS_CLIENT_ID="your WorkOS Client ID"]]'

What should I see?

A JSON response carrying your user object, an access_token, and a refresh_token. The access token is the long dotted string.

That string is a JWT. Your API spends the rest of this project deciding whether to trust it.

  • Copy the access_token value into the same scratch file as your Client ID.
  • Keep that file out of any screenshot you share, because the token works like a password until it expires.

No token in the response?

  • authorization_pending means the browser confirmation has not landed yet. Finish it, then rerun the command.
  • expired_token means the code aged out of its five minute window. Run the first command again for a fresh pair.
  • On Windows PowerShell, run curl.exe instead of curl and put the whole command on a single line.

Something else going wrong? Why is my device token request failing?

You have a real, signed token and absolutely nothing checking it. Next up you will run an API that hands out data to anyone who asks, token or no token.

Build an Unprotected API Route

You finished the last step holding a real WorkOS access token. Right now there is nothing on your machine that cares whether you have one.

Before you can protect a route, you need a route worth protecting. In this step you'll stand up a tiny Node.js API with a single endpoint.

That endpoint hands its data to anyone who asks. You'll prove it by calling it with no token at all, and that unprotected 200 becomes the baseline everything else in this project is measured against.

In this step, get ready to:
  • Set up a Node project folder with its own package.json file.
  • Write an API server with one route that returns JSON.
  • Call that route with curl and no token, then watch it answer anyway.
Set up a Node project for your API

Every Node project starts with a folder and a package.json file. That file is how npm knows which packages your API depends on.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir workos-api
cd workos-api
  • Create a package.json for the project by running this command:
npm init -y
  • Read the terminal output. You should see the contents of your new package.json printed back, including a name line reading workos-api.
  • Install the two packages you'll verify tokens with later by running this command:
npm install @workos-inc/node jose
  • Confirm the install landed by running this command:
ls
  • You should see node_modules, package-lock.json and package.json listed.

Install didn't finish?

Check that your terminal prompt shows workos-api. Running the install from the wrong folder puts the packages somewhere you won't find them.

A stalled or half-finished install is usually a network or proxy problem. Delete node_modules and run the install command again.

Still stuck? Help me read this npm install error

Write a server that listens on port 3000

Your API is going to be a single file. Before you write it, tell Node to read that file as an ES module so import statements work.

  • Press **Cmd+Space** (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the workos-api folder you just created, then click Open.
  • Open package.json from the Explorer sidebar.
  • Find this line:
  "name": "workos-api",
  • Add a type line directly below it, so the two lines read like this:
  "name": "workos-api",
  "type": "module",

What does the type line do?

Node reads .js files as CommonJS by default, where you pull packages in with require.

Setting the type to module switches the whole folder to ES modules. Every file here now uses import instead.

  • Save package.json.
  • Create a new file called server.js by clicking the New File icon at the top of the Explorer sidebar.
  • Paste this into server.js:
import { createServer } from "node:http";

// Every request lands here, whatever path it asks for
const server = createServer((req, res) => {
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "not_found" }));
});

// Port 3000 is where curl sends its requests
server.listen(3000, () => {
  console.log("API listening on http://localhost:3000");
});

What does this code do?

  • createServer builds an HTTP server and hands every incoming request to the function you pass it.
  • res.writeHead(404, ...) answers every request with a not found status, because you haven't added a route yet.
  • server.listen(3000, ...) opens port 3000 and prints a line once the server is ready to take requests.
  • Save server.js.

Starting the server takes over your terminal window and keeps it busy. Press Ctrl+C any time you want that terminal back.

  • Switch back to your terminal and start the server by running this command:
node server.js

You should see API listening on http://localhost:3000 printed in the terminal.

Server won't start?

If the terminal complains that it cannot use an import statement outside a module, your type line in package.json did not save.

If it complains that the address is already in use, another process is holding port 3000. Stop that process, or change 3000 in server.js to another port.

Something else? Help me start my Node server

Prove the route answers anyone

A server that answers everything with a 404 proves nothing. Give it the one route this whole project is about.

  • Switch back to server.js in VS Code.
  • Find this line:
const server = createServer((req, res) => {
  • Add this code directly below it:
  // The only route this API exposes, and it checks nothing
  if (req.method === "GET" && req.url === "/api/data") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: "Top secret data", items: ["alpha", "beta"] }));
    return;
  }

What does this code do?

  • The condition matches only GET requests for /api/data, so every other path still falls through to the 404 below.
  • res.writeHead(200, ...) answers with a success status and tells the caller the body is JSON.
  • return stops the handler right there, so the 404 never runs for this route.
  • Nothing in this block looks at who is calling. There is no header check and no token check anywhere in it.
  • Save server.js.
  • Switch to the terminal running your server and press Ctrl+C to stop it.
  • Restart it with your new route by running this command:
node server.js

You should see API listening on http://localhost:3000 again.

  • Leave the server running, and in VS Code click Terminal in the top menu bar.
  • Click New Terminal.

Before you run the next command, what do you think a request carrying no Authorization header gets back from this route?

  • Call your route with no token at all by running this command:
curl -i http://localhost:3000/api/data

The first line of the response reads HTTP/1.1 200 OK, and the last line is your JSON payload.

Why this matters

You sent no token, no key and no header of any kind. The API handed over its data anyway.

That 200 is your baseline. Every status you see in the rest of this project is measured against it.

Connection refused?

Check the terminal running the server. If it dropped back to a prompt, the server stopped and needs restarting.

If you get a 404 body instead, the path is off. The route only matches /api/data exactly.

Still nothing back? Help me debug this curl request

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and that your server is still running.

ⓧ I'd like to double check the full code

Here is the whole of server.js as it should look at the end of this step.

import { createServer } from "node:http";

// Every request lands here, whatever path it asks for
const server = createServer((req, res) => {
  // The only route this API exposes, and it checks nothing
  if (req.method === "GET" && req.url === "/api/data") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: "Top secret data", items: ["alpha", "beta"] }));
    return;
  }

  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "not_found" }));
});

// Port 3000 is where curl sends its requests
server.listen(3000, () => {
  console.log("API listening on http://localhost:3000");
});

Your API answers everyone, and now you have the receipt to prove it. Next up, you'll put middleware in front of that route and watch a tampered token bounce off it with a 401.

Verify Tokens Against the JWKS

Last step you proved the problem. Your /api/data route hands its payload to anyone who asks, token or no token.

An access token is a few chunks of base64 with a signature stapled on the end. Anyone can type the first chunks, so that signature is the only thing that makes a token worth believing.

WorkOS publishes the public keys for your application as a JWKS. In this step you'll fetch that key set and check every incoming token against it.

In this step, get ready to:
  • Create an auth middleware file that loads your application's key set.
  • Verify the bearer token's signature before your route runs.
  • Watch a tampered token come back as a 401.
Point your middleware at the WorkOS key set

Your API needs the public half of the key WorkOS signs its tokens with. WorkOS serves that key set at an address built from your Client ID.

  • Create a home for your middleware by running these commands from the folder that holds server.js:
mkdir middleware
touch middleware/auth.js
  • Confirm the file exists by running:
ls middleware
  • You should see auth.js in the output.
  • Open middleware/auth.js in your code editor.
  • Load your application's key set by adding this code to middleware/auth.js:
import { createRemoteJWKSet } from 'jose';

// Your Client ID tells WorkOS which application's public keys to hand back
const clientId = '[[WORKOS_CLIENT_ID="your WorkOS Client ID"]]';

// WorkOS publishes those public keys at this address
const jwksUrl = `https://api.workos.com/sso/jwks/${clientId}`;

// createRemoteJWKSet fetches the keys on demand, then caches them between requests
export const jwks = createRemoteJWKSet(new URL(jwksUrl));

console.log('Trusting keys from', jwksUrl);

What does this code do?

  • jwksUrl pins your Client ID onto the WorkOS key set address, so the keys you fetch belong to your application and nobody else's.
  • createRemoteJWKSet builds a key set that caches what it fetches, so you are not making an outbound request to WorkOS on every API call.
  • The console.log prints that address whenever the file loads, which catches a mistyped Client ID in seconds instead of in a confusing 401 later.
  • Save middleware/auth.js.
  • Check the key set address by running:
node middleware/auth.js
  • You should see Trusting keys from https://api.workos.com/sso/jwks/ followed by your Client ID.

Nothing printed, or an error instead?

  • Run the command from the folder that holds server.js, not from inside middleware.
  • If Node complains about the import line, add "type": "module" to your package.json and run it again.

Still stuck? Why does Node error when I run my middleware file?

Check the signature before the route runs

Reading a bearer token off a header is easy. Trusting it takes one more move, which is checking its signature against the keys you just loaded.

  • In middleware/auth.js, find the import line at the very top:
import { createRemoteJWKSet } from 'jose';
  • Add jwtVerify to it, so the line now reads:
import { createRemoteJWKSet, jwtVerify } from 'jose';
  • Add the middleware at the bottom of middleware/auth.js, below the console.log line:
export async function requireUser(req, res, next) {
  // Bearer tokens arrive as "Bearer <token>" in the Authorization header
  const header = req.headers.authorization ?? '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;

  // No token at all is an easy rejection, no key set needed
  if (!token) {
    return res.status(401).json({ error: 'missing_token' });
  }

  try {
    // jwtVerify checks the signature against the public keys in the key set
    await jwtVerify(token, jwks);
    return next();
  } catch (error) {
    // jose tags every verification failure with a code starting ERR_
    if (typeof error?.code === 'string' && error.code.startsWith('ERR_')) {
      return res.status(401).json({ error: 'invalid_token' });
    }

    return next(error);
  }
}

What does this code do?

  • header.slice(7) drops the Bearer prefix and keeps the token itself.
  • jwtVerify throws unless the signature matches a key in the set, so every line after it is running on a token you can trust.
  • next() hands the request on to your route, which now only ever sees verified callers.
  • Anything jose refuses becomes a 401 carrying invalid_token, and the token itself never appears in the response.
  • Save middleware/auth.js.
  • Check the file still parses by running:
node middleware/auth.js
  • You should see the same Trusting keys from line and no error beneath it.

Seeing a syntax error?

  • Keep requireUser at the bottom of the file, below the jwks line it depends on.
  • Check the import line now names both createRemoteJWKSet and jwtVerify inside the braces.

Still red? Why is my auth middleware file throwing a syntax error?

✔️ Awesome, I've got everything!

Great, double check you've saved middleware/auth.js before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm all four pieces are in middleware/auth.js, in this order:

  • The jose import at the top, naming both createRemoteJWKSet and jwtVerify.
  • The clientId and jwksUrl constants.
  • The exported jwks key set, with the console.log beneath it.
  • The exported requireUser function at the bottom.
Put the middleware in front of your route

Middleware only runs when a route asks for it. Express takes it as an extra argument on the route, sitting in front of the handler.

  • In server.js, add this line below the imports at the top of the file:
import { requireUser } from './middleware/auth.js';
  • Scroll down to the line that registers your route:
app.get('/api/data', (req, res) => {
  • Slot requireUser in front of the handler, so the line now reads:
app.get('/api/data', requireUser, (req, res) => {
  • Save server.js.
  • Stop your running server with Ctrl+C.
  • Start it again by running:
node server.js
  • Send a request with no token at all, exactly as you did in the last step:
curl -i http://localhost:3000/api/data
  • You should see HTTP/1.1 401 Unauthorized and a body of {"error":"missing_token"}.

Same route, same command, different answer. Your payload is behind a gate now.

Connection refused?

  • Check the port your server prints when it starts and swap it into the URL in place of 3000.
  • Run the curl command in a second terminal, because the first one is busy running your server.

Still nothing? Why does curl say connection refused when my Node server is running?

  • Prove a real token still gets through by running this command, pasting in the access token you copied earlier:
curl -i -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data
  • You should see HTTP/1.1 200 OK and the JSON payload your route returns.

Real token, still getting a 401?

  • Check the Client ID in middleware/auth.js matches the one in your WorkOS dashboard, because keys from another application will never match your token.
  • Paste the token as one unbroken string, since a stray line break or trailing space changes the bytes jose verifies.

Need a hand? Why is my valid WorkOS access token failing signature verification?

Now for the real test. Before you run the next command, make a call: if you change a single character inside the token, does your API notice?

  • Send a tampered token by running the same command with one character in the last section of the token changed:
curl -i -H "Authorization: Bearer your-access-token-with-one-character-changed" http://localhost:3000/api/data

You should see HTTP/1.1 401 Unauthorized and a body of {"error":"invalid_token"}.

Why one character was enough

The signature on the end of a token is computed over the exact bytes in front of it. Change anything, anywhere, and it stops matching the public key WorkOS published.

That is the entire difference between reading a token and trusting one.

Your route now answers only callers holding a genuine token. Next up, you'll open that verified token and pull out who the caller actually is.

Return the Caller's Claims

Your middleware now throws out any token whose signature does not check out. That guard works, but the route on the other side of it still answers every caller with the same fixed payload.

A verified token is more than a yes or no. Inside it are claims that name exactly who is calling and which sign-in session they are calling from.

Right now those claims get verified and then thrown away. In this step you keep them, hand them to the route, and answer with the caller's subject and session id.

In this step, get ready to:
  • Attach the verified claims to the request inside middleware/auth.js.
  • Return the caller's subject and session id from the /api/data route.
  • Confirm the same route still answers 401 when no token is sent.
Attach the verified claims to the request

Signature verification already opens the token up to check it. The decoded body comes back as the payload, and your middleware currently drops it on the floor.

  • Switch back to middleware/auth.js in your editor.
  • Find the line inside the try block that verifies the token:
await jwtVerify(token, JWKS);
  • Replace that single line with these four lines, so the payload is kept instead of discarded:
// jwtVerify hands back the decoded payload once the signature checks out
const { payload } = await jwtVerify(token, JWKS);
// Keep the claims on the request so the route can read them later
req.claims = payload;
console.log("Verified claims:", payload);

Why hang the claims off the request?

The middleware is the only place that has proven the token is genuine. Anything it learns there has to travel forward, or the route ends up parsing the token a second time and trusting it blindly.

Putting the claims on the request object makes them available to every handler behind the guard. The route reads verified data and never touches the raw token.

  • Save middleware/auth.js.
  • Press Ctrl+C in the terminal running your server to stop it.
  • Start it again by running this command:
node server.js
  • Open a second terminal window and call the route, with your-access-token-here swapped for the access token you copied earlier:
curl -s -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data

Look at the terminal running your server. It now prints Verified claims: followed by an object that includes sub and sid.

No claims printed in the server terminal?

Check that you restarted the server after saving. Node loads your files once at startup, so an old process keeps running the old middleware.

Check that the two new lines sit inside the try block. Outside it, they never run on a verified token.

Why is my middleware not logging the claims after jwtVerify?

Return the subject and session id from the route

The claims are now sitting on the request, but the route still answers with the same fixed message. Two of those claims are worth surfacing first.

  • Switch back to server.js in your editor.
  • Find the line inside your /api/data handler that sends the JSON body:
res.end(JSON.stringify({ message: "This is protected data." }));
  • Replace it with this, so the reply carries the caller's identity alongside the payload:
// req.claims was attached by the middleware after the signature check passed
res.end(
  JSON.stringify({
    message: "This is protected data.",
    sub: req.claims.sub,
    sid: req.claims.sid,
  })
);

What are sub and sid?

Every JWT carries a set of claims describing who it was issued to. sub is the subject, the stable id of the user this token belongs to.

sid is the session id, naming the single sign-in session the token came out of.

Together they let your API map the caller to a user record and to one session. That is the difference between knowing a request is authentic and knowing whose request it is.

  • Save server.js.
  • Press Ctrl+C in the server terminal to stop it.
  • Start it again by running this command:
node server.js

Before you send the request, what do you think comes back in the JSON body this time?

  • Call the route with your token again by running the same command in your second terminal:
curl -s -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data

The reply now has three fields instead of one. Alongside the message you see a sub value and a sid value read straight out of your verified token.

Seeing null for sub and sid?

Check that the middleware assigns req.claims to the same request object the route later reads. Both must be the request passed into your handler.

Check that you destructured the payload. Without const { payload }, the claims sit one level deeper than the route expects.

Why are sub and sid coming back as null from my route?

Check the guard still holds

Reading claims changed what the route returns on success. It should not have changed anything about the request that never gets past the middleware.

Before you run the next command, do you expect a 401 or the new claims body?

  • Call the route with no Authorization header by running this command:
curl -i -s http://localhost:3000/api/data

You should see a 401 status and no claims in the body. The middleware still rejects the request before the route ever reads req.claims.

Why this check matters

A route that reads claims has a new way to break. If the guard ever lets an unverified request through, the handler reaches for req.claims on something that was never checked.

Running the no-token call after every change to the response keeps that failure honest. The 401 is your proof that the claims only ever come from a token WorkOS signed.

Your API now knows who is calling and which session they are calling from. Next up, you will break the key server on purpose and find out why a 401 is the wrong answer when verification itself is unavailable.

Handle an Unreachable Key Server

Your route now reads real claims out of a verified token and hands back the caller's sub and sid. Every failure on the way there gets the same answer though: 401.

That is the right answer when the token is bad. It is the wrong answer when your API could not reach the key server at all, because then you never checked anything.

A 401 tells a client its session is dead, and a well behaved client signs its user out. One slow moment at the key server would log out everybody who is signed in.

In this step, get ready to:
  • Point your key set lookup at a host that never answers.
  • Watch a perfectly good token come back rejected.
  • Tell an outage apart from a bad token, and answer 503 with a Retry-After header.
Point the key set at a host that never answers

You cannot take WorkOS down to test this. You can point your key set lookup at an address that will never answer, which looks identical from inside your code.

Why 192.0.2.1?

The 192.0.2.0/24 range is reserved for documentation, so nothing on the public internet routes it.

A connection to 192.0.2.1 hangs rather than failing fast. That is exactly what a wobbling key server feels like.

  • In middleware/auth.js, search for api.workos.com.
  • Find the key set URL on that line:
https://api.workos.com/sso/jwks/
  • Replace api.workos.com with 192.0.2.1, so the URL now reads:
https://192.0.2.1/sso/jwks/
  • Save middleware/auth.js.
  • Stop your running server with Ctrl+C.
  • Start it again by running this command:
node server.js

Before you run the next command, what status do you think your API returns for a token that is completely valid?

  • Call your route with your real access token by running this command:
curl -i -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data

The request hangs for about five seconds, then comes back 401 Unauthorized. If your catch block rethrows codes it does not recognise, you get a 500 instead.

Both answers are wrong, and that is the point of this step. Your API never got the keys, so it has no grounds to tell that caller their token is invalid.

Response came back instantly?

Some networks reply with an unreachable error instead of dropping the packet, so you get a connection failure rather than a five second wait. The status you see is the same, and the fix in the next substep covers both.

If you got a fast 401 with no pause at all, check you saved the file and restarted the server before running curl.

Why did my request return instantly instead of timing out?

Answer 503 when the key server is the problem

Every failure that jose throws carries a stable code, and the JWKS ones are the trap.

The code ERR_JWKS_TIMEOUT means the key set never arrived. It says nothing at all about the token.

  • In the catch block of middleware/auth.js, add this above the line that returns the 401:
// jose raises ERR_JWKS_TIMEOUT when it gives up waiting for the key set
// A DNS or connection failure throws from fetch before jose ever sees it
const keyServerUnavailable =
  error?.code === "ERR_JWKS_TIMEOUT" ||
  (error?.name === "TypeError" && Boolean(error.cause));

if (keyServerUnavailable) {
  res.set("Retry-After", "5");
  return res.status(503).json({ error: "verification_unavailable" });
}

What does this code do?

  • The ERR_JWKS_TIMEOUT check catches the case where the key set fetch was aborted.
  • The TypeError check catches a DNS or connection failure, which fetch throws before jose gets a look at it.
  • Retry-After tells the caller how many seconds to wait, so a client can back off instead of signing its user out.
  • A 503 puts the fault on your side. A 401 puts it on the caller.
  • Save middleware/auth.js.
  • Stop your server with Ctrl+C.
  • Start it again by running:
node server.js
  • Call your route again with the same command:
curl -i -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data

After the same pause, the first line now reads HTTP/1.1 503 Service Unavailable, followed by a Retry-After: 5 header.

Still seeing a 401 or a 500?

If Node reports that error is not defined, your catch block names the caught value something else, such as err. Match the name your file already uses.

If you still get a 401, the new block is probably sitting below the 401 return, so the function exits before it ever runs.

Why is my 503 branch never running?

✔️ Awesome, I've got everything!

Great, double check you've saved middleware/auth.js before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both pieces are in middleware/auth.js, in this order.

  • The key set URL pointing at 192.0.2.1, from the edit at the start of this step.
  • The keyServerUnavailable check and its 503 branch, sitting above your existing 401 return.

You put the real URL back in the next substep, so leave 192.0.2.1 in place for now.

Put the real key server back

A 503 for everything would be just as dishonest as a 401 for everything. Point the key set back at the real host and check that a bad token still gets turned away.

  • In middleware/auth.js, search for 192.0.2.1.
  • Find the key set URL on that line:
https://192.0.2.1/sso/jwks/
  • Replace 192.0.2.1 with api.workos.com, so the URL reads:
https://api.workos.com/sso/jwks/
  • Save middleware/auth.js.
  • Stop your server with Ctrl+C.
  • Start it again by running:
node server.js

You are about to send your real token, then the same token with its last character changed. What status do you expect from each one?

  • Call your route with your real access token by running:
curl -i -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data
  • Call it again with the last character of your token changed by running:
curl -i -H "Authorization: Bearer your-tampered-token-here" http://localhost:3000/api/data

The real token answers HTTP/1.1 200 OK with your sub and sid in the body.

The tampered one answers HTTP/1.1 401 Unauthorized with no Retry-After in sight. Same route, same middleware, three different answers depending on what actually went wrong.

Still getting a 503?

Check the URL for a leftover digit from 192.0.2.1. A half edited host is still an unreachable host.

If the URL looks right, the running process is probably still the old one. Stop it and start it again so the new file is loaded.

Why is my API still returning 503 after fixing the JWKS URL?

Your API now says what it actually means: the token is bad, or verification is unavailable. Next up, you'll send an expired token, get the failure you expect, then refresh it and watch the same route answer 200 again.

Test Expired and Refreshed Tokens

Last step you proved your API can tell a broken key server apart from a broken token. It answers 503 when it cannot verify at all, and 401 when it can verify and the token is genuinely wrong.

There is one failure left, and it is the one your real users hit every single day. Tokens expire.

In this step you will send the token you copied back in Step 1, watch your route reject it, then refresh it through WorkOS and get a 200 out of the same unchanged route.

In this step, get ready to:
  • Point your key set lookup back at WorkOS after last step's sabotage.
  • Send your aged-out access token and watch it come back as 401.
  • Refresh your token and get a 200 with your claims from the same route.
Point the key set lookup back at WorkOS

Your middleware is still aimed at the unreachable host you forced in the last step. Every request answers 503 until you put the real key set back.

  • Return to middleware/auth.js in your editor.
  • Find the change you made in the last step that pointed the key set lookup at an unreachable host.
  • Undo that change so the lookup points back at WorkOS again.
  • Save middleware/auth.js.

Where your key set actually lives

WorkOS publishes the public keys for your application as a JWKS at https://api.workos.com/sso/jwks/ followed by your Client ID.

The Node SDK builds that same URL for you with workos.userManagement.getJwksUrl(). Whichever shape your file uses, the host you are aiming at should read api.workos.com again.

  • Stop your running server with Ctrl+C so it drops the old code.
  • Start it again by running this command:
node server.js

Before you run the next command, do you think your original token will come back as 200 or 401? It has been sitting in your terminal since Step 1.

  • Send the same request you have been running, with your original token still in place, by running this command:
curl -i -H "Authorization: Bearer your-access-token-here" http://localhost:3000/api/data

Look at the first line of the response, which is the status line. Pick the tab below that matches what you see.

✔️ I see 401 Unauthorized

That is exactly the answer you want. The 503 is gone, so your key set lookup is reaching WorkOS again.

The signature on your token is still perfectly valid. It failed on the exp claim, which passed while you were working through the last few steps.

ⓧ I see 200 OK

Your token has not aged out yet. WorkOS AuthKit access tokens last 5 minutes by default, and yours is still inside that window.

  • Wait a few minutes, then run the same command again to get your 401.

ⓧ I see 503 Service Unavailable

The key set lookup is still pointing at the unreachable host from the last step.

  • Reopen middleware/auth.js and confirm the host reads api.workos.com.
  • Save the file, then stop and restart your server so the edit takes effect.

Still getting 503?

Your machine may be offline, or the URL may still be missing your Client ID. Why is my key set lookup still failing?

Refresh your token through the hosted login page

An expired token is not a broken token. The session behind it can still be very much alive, and refreshing mints a new one from that same session.

  • Return to your app's hosted AuthKit login page in the browser.
  • Sign in again with the same account you used in Step 1.
  • Copy the new access token the same way you copied the first one.
  • Paste the old and the new token onto two lines in your editor so you can compare them.

The two strings match for a while, then diverge after the first dot. That middle section is the JWT payload, where the exp and iat claims live.

How a real app refreshes a session

Signing in again is the blunt version of a refresh, and it is fine while you are poking at your API with curl. Sending real users back to a login screen every five minutes is not fine.

A real client keeps the refresh token it got at sign-in and exchanges it at https://api.workos.com/user_management/authenticate with grant_type set to refresh_token.

Refresh tokens may only be used once. The response hands back a replacement, and your job is to store that one in place of the old value.

Send the fresh token and get your 200 back

Nothing in your middleware has changed since the 401. Before you run this, do you expect the route to answer 200 or 401 now?

  • Send the request again with your refreshed token by running this command:
curl -i -H "Authorization: Bearer your-new-access-token-here" http://localhost:3000/api/data

You should see HTTP/1.1 200 OK, followed by a JSON body carrying your claims. Your own ids will differ from the ones below.

{
  "sub": "user_01HBEQKA6K4QJAS93VPE39W1JT",
  "sid": "session_01HQSXZGF8FHF7A9ZZFCW4387R"
}

What one route just proved

You have now driven four different answers out of a single unchanged endpoint:

  • 401 for a token whose signature did not check out.
  • 503 with Retry-After when the key server could not be reached.
  • 401 for a token whose signature was fine but whose clock had run out.
  • 200 with real claims once the token was refreshed.

That third and second line are the pair most APIs get wrong. Collapsing them into one 401 signs every user out during a network blip.

Still seeing 401 with the fresh token?

  • Access tokens are long and wrap across several lines, so check you copied the whole string including the last character.
  • A stray space or newline inside the quotes breaks the signature check, so retype the quotes around the token.
  • If you took more than five minutes to paste it, the new token has already expired too.

Why is my freshly issued WorkOS token still rejected?

Your access token is a live credential. Scroll your terminal so the curl line is off screen before you capture anything.

Secret mission

Test Your Auth Contract in One Command

Your route answers 200, 401, and 503 in all the right places, but proving that takes a fresh round of curl runs every time you touch the middleware. Write a small Node script that checks every token case in one command and exits non-zero the moment the contract breaks.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your API runs entirely on your own machine and your WorkOS application sits on the free developer tier, so there are no ongoing costs.

Resources you used:

  • Your Node API server (server.js), running locally.
  • Your auth middleware (middleware/auth.js).
  • Your project folder, including package.json and node_modules.
  • Your WorkOS application, and the Client ID your middleware uses to fetch its JWKS.
  • The access token you pasted into your terminal.

Keep everything running

No action needed. Choose this if you're still actively building on top of your middleware.

  • Leave your server running so you can keep sending requests to /api/data.
  • Leave your WorkOS application in place, since an idle application on the developer tier costs nothing.
  • Leave your WorkOS account open too. Deleting the account is not part of this project.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files stay exactly where they are.

  • Stop your API server by pressing Ctrl+C in the terminal running it.
  • Leave server.js and middleware/auth.js untouched on disk.
  • Start the server again later by running this command inside your project folder:
node server.js

Your token won't survive the break

Access tokens are short lived, so the one you refreshed earlier will have expired by the time you come back.

Sign in through your app's hosted AuthKit login page again to copy a fresh token before you send another request.

Delete - I don't want to use this again

Remove all project resources and start fresh. Choose this if you're finished with the API and don't want the files or the WorkOS application hanging around.

  • Stop your API server by pressing Ctrl+C in the terminal running it.
  • Delete your project folder on macOS or Linux by running this command:
rm -rf [[PROJECT_FOLDER_PATH="path to your project folder"]]
  • Delete the same folder on Windows by running this command instead:
rmdir /s /q [[PROJECT_FOLDER_PATH="path to your project folder"]]
  • Confirm the folder is gone by running this command:
ls
  • Close the terminal window you used for your curl requests, so your access token no longer sits in the scrollback.
  • Return to the WorkOS Dashboard in your browser.
  • Open the application you created for this project.
  • Delete that application from its settings page.

Can't find the application in the Dashboard?

Check that you're looking at the same environment you used during the project. The Client ID shown in the Dashboard is the one your middleware fetched its JWKS from.

Still stuck? Help me find and delete my WorkOS application.

Nice Work!

Nice Work!

Nice work. Your API no longer trusts whatever token it is handed, it checks the signature first and answers honestly when it cannot check at all.

You've learned how to:

  • Verify a WorkOS access token against the remote JWKS for your Client ID, so a tampered token never reaches your route.
  • Read the sub and sid claims out of a verified token and hand them back from your protected route.
  • Tell a bad token apart from an unreachable key server, answering 401 for one and 503 with Retry-After for the other.
  • Secret Mission: Take on the optional challenge that pushes your middleware a step further.

Ready to quiz yourself?