End a Live Login Session

Build an OIDC endpoint that ends active sessions with signed logout tokens.

Introduction

30 Second Summary

Removing someone from a company directory does not always sign them out of apps they already opened. They can stay signed in until each app eventually logs them out.

In this project, you will build a local Node.js server that receives provider-initiated logout requests. You will validate signed tokens using OpenID Connect Back-Channel Logout before ending the matching session.

What You'll Build

Your finished demo shows a protected route switching from account data to a 401 response after an external logout request.

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

  • A local identity provider simulator that shows its public key on a JWKS page. It also prints signed logout tokens in your terminal.
  • A tested logout endpoint that returns 200 for a valid token. It returns 400 with a named reason for each broken token.
  • A live protected session that you can end from another terminal. Its protected route changes from account data to 401. A replay guard refuses the same token twice.
  • An optional Secret Mission to push your skills further.

Are there any prerequisites?

You need Node.js installed with basic HTTP knowledge. The jose library runs locally without an identity provider account.

Before We Start

A provider-initiated logout reaches your server without a user clicking anything. Your local environment needs the right tools before you can receive that request.

In this step, you will prepare Node.js to run the server on your laptop. Its included npm package manager installs the libraries used later.

You also need a terminal for commands. Visual Studio Code gives you an editor for the project files.

A browser displays the local public key document. curl sends HTTP requests to your server.

In this step, get ready to:
  • Prepare a terminal with Node.js and npm.
  • Set up Visual Studio Code for editing.
  • Confirm the browser and curl testing tools.
Check Node.js from your terminal

The terminal gives you a direct way to run the server scripts. Use the instructions for your operating system to open it.

macOS

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into the search field.
  • Press Return to open Terminal.

Windows

  • Press the Windows key to open the Start menu.
  • Type Windows PowerShell into the search field.
  • Select Open to start Windows PowerShell.
  • Check whether Node.js and npm are available by running these commands:
node --version
npm --version

What Do These Checks Prove?

  • The first command asks Node.js to print its installed version.
  • The second command confirms that npm is available for installing project dependencies.

✔️ I see two version numbers

Great, your local JavaScript toolchain is ready. Your terminal can run Node.js scripts and npm commands.

ⓧ npm is missing

A working Node.js installer normally provides npm too. Reinstalling Node.js restores both parts of the toolchain.

  • Visit the official Node.js download page.
  • Download the LTS installer for your operating system.
  • Run the downloaded installer.
  • Keep the default installation options selected.
  • Approve the operating system permission request if it appears.
  • Return to the terminal from earlier.
  • Run the two version checks above again.

ⓧ Node.js is missing

The official installer provides Node.js with npm. Your operating system may ask for permission before the installation begins.

  • Visit the official Node.js download page.
  • Download the LTS installer for your operating system.
  • Run the downloaded installer.
  • Keep the default installation options selected.
  • Approve the operating system permission request if it appears.
  • Return to the terminal from earlier.
  • Run the two version checks above again.

Still Missing Node.js or npm?

Close the terminal after the installation finishes. Open a fresh terminal so it can load the updated command path.

If either check still fails, help me troubleshoot my Node.js installation.

Set up Visual Studio Code and your browser

Your editor holds the server code you build in the next steps. First, check whether Visual Studio Code is already available.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Visual Studio Code into the search field.
  • Press Return on macOS or select Open on Windows.

✔️ Visual Studio Code opens

Your code editor is ready. Keep it open for the project steps ahead.

ⓧ Visual Studio Code is not installed

Install Visual Studio Code with the instructions for your operating system.

macOS

  • Visit the official Visual Studio Code download page.
  • Download the Universal .dmg installer.
  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into the Applications folder.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into the search field.
  • Press Return to open Visual Studio Code.

Windows

  • Visit the official Visual Studio Code download page.
  • Download the User Installer for your computer.
  • Run the downloaded installer.
  • Keep the default installation options selected.
  • Press the Windows key to open the Start menu.
  • Type Visual Studio Code into the search field.
  • Select Open to start Visual Studio Code.

The browser becomes your first window into the local server. A successful page load confirms that it is ready for the JWKS check later.

You should see the Node.js download page. Your browser is ready to display local endpoints later.

Does the Page Fail to Load?

Check that your internet connection is active. Try the link again in another browser if the page remains unavailable.

If the problem continues, help me troubleshoot why the Node.js website will not load.

Verify the local request tools

The final tool check covers the runtime, package manager, and request client together. This gives you one clear signal that the local toolkit is ready.

Before you run the final check, how many version results do you expect to see?

  • Verify all three command-line tools by running these checks:
node --version
npm --version
curl --version

What Does This Check Cover?

  • Node.js runs the server and token scripts.
  • npm installs the project dependencies.
  • curl sends requests to the logout endpoint from your terminal.

✔️ I see three version results

That is the setup complete. Your terminal can run Node.js, install packages, and send local HTTP requests.

ⓧ curl is missing

curl provides installation packages for several operating systems. Use its official download list to choose the option for your computer.

  • Visit the official curl download page.
  • Select the package for your operating system.
  • Follow the installation instructions supplied with that package.
  • Return to the terminal from earlier.
  • Run the final three checks again.

Is curl Still Missing?

Close the terminal after installing curl. Open a fresh terminal before repeating the checks.

If curl remains unavailable, help me fix my curl installation.

Your local toolkit is ready. Next, you will create the Node.js project and expose a public key through a local JWKS endpoint.

Set Up the JWKS Server

An OIDC back-channel logout endpoint can trust a token after it verifies the signature with the matching public key. That trusted key must be reachable before you can test a provider-initiated logout request.

This step turns your local Node.js project into a small identity provider stand-in. You will generate an RSA key pair. A JWKS endpoint will publish the public half.

In this step, get ready to:
  • Initialize a local Node.js project with the jose dependency.
  • Generate an RSA signing key pair for local testing.
  • Publish the public key through a local JWKS endpoint.
Create the local Node project

The jose library handles the key formats used for signing. It also provides the public-key representation that your endpoint will use for verification.

  • Create the project folder on your Desktop by running these commands:
cd ~/Desktop
mkdir oidc-backchannel-logout
cd oidc-backchannel-logout

What do these commands do?

The first command moves your terminal to the Desktop. The next command creates the oidc-backchannel-logout folder.

The final command makes that folder your current location. Every project file created below stays inside it.

Your terminal prompt should now end with oidc-backchannel-logout.

  • Prepare the project manifest with its dependency and server scripts by running these commands:
npm init -y
npm install jose
npm pkg delete scripts.test
npm pkg set type=module scripts.jwks="node src/jwks-server.js" scripts.start="node src/server.js"

What do these commands do?

  • The first command creates package.json with default project details.
  • The second command installs jose as a project dependency.
  • The third command removes the unused default test script.
  • The final command enables ES modules. It also adds scripts for the JWKS server and the application server.
  • Verify the saved scripts and dependency by running this command:
npm pkg get scripts dependencies

What should I see?

The jwks script should point to src/jwks-server.js. The start script should point to src/server.js.

The dependencies output should include jose. This confirms that package.json contains the required project setup.

Good work: your project can now load jose through its manifest.

Missing the scripts or dependency?

  • Confirm that your terminal prompt ends with oidc-backchannel-logout.
  • Check your internet connection if the package installation did not finish.
  • Run the project setup commands again from the oidc-backchannel-logout folder.

Help me diagnose my Node project setup.

Generate the local signing keys

A private key can create trusted tokens. This one is local test material that never needs to leave your laptop.

Why generate keys locally?

Local keys let you simulate an identity provider without creating an external account. You control both sides of the signing flow while keeping this project free.

A real deployment receives the provider's public keys from its JWKS URL. Your local server recreates that trust boundary on your laptop.

  • Switch to your code editor.
  • Select the oidc-backchannel-logout folder from your Desktop.

You should see package.json in your editor's file sidebar.

  • Create a src folder inside oidc-backchannel-logout.
  • Create generate-keys.js inside the src folder.

You should now see src/generate-keys.js in the file sidebar.

  • Add the key-generation logic to src/generate-keys.js by pasting this code:
import { mkdir, writeFile } from 'node:fs/promises'
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose'

// Generate an extractable key pair for local test signing.
const { privateKey, publicKey } = await generateKeyPair('RS256', {
  extractable: true,
})

// Store the private key as PEM for the token-signing script.
const privateKeyPem = await exportPKCS8(privateKey)

// Add public metadata so verifiers can select this signing key.
const publicKeyJwk = await exportJWK(publicKey)
publicKeyJwk.kid = 'logout-key-1'
publicKeyJwk.use = 'sig'
publicKeyJwk.alg = 'RS256'

// Keep both test keys in one dedicated directory.
await mkdir('keys', { recursive: true })
await writeFile('keys/logout-private-key.pem', privateKeyPem)
await writeFile('keys/logout-public-key.jwk', JSON.stringify(publicKeyJwk, null, 2))

console.log('Created keys/logout-private-key.pem')
console.log('Created keys/logout-public-key.jwk')

What does this code do?

  • The generateKeyPair() call creates an extractable RSA key pair using RS256.
  • The exportPKCS8() call converts the private key into a PEM string for the signing script.
  • The exportJWK() call converts the public key into a JSON Web Key.
  • The kid value gives the key a stable identifier that a verifier can match against a token header.
  • The final writes create keys/logout-private-key.pem and keys/logout-public-key.jwk.
  • Save src/generate-keys.js.
  • Generate the local RSA key pair by running this command:
node src/generate-keys.js

What should I see?

The terminal should confirm that it created keys/logout-private-key.pem.

It should also confirm that it created keys/logout-public-key.jwk. Your private signing material and publishable public key now exist as separate files.

Your test identity is taking shape: both halves of the RSA key pair now exist on disk.

Key files missing?

  • Confirm that src/generate-keys.js is saved inside the oidc-backchannel-logout folder.
  • Check that package.json contains "type": "module".
  • Check that jose appears under dependencies.

Help me fix my key-generation script.

✔️ Awesome, I've got everything!

Your key-generation file is complete. Keep both generated key files inside the keys folder.

ⓧ I'd like to double check the full code

import { mkdir, writeFile } from 'node:fs/promises'
import { exportJWK, exportPKCS8, generateKeyPair } from 'jose'

// Generate an extractable key pair for local test signing.
const { privateKey, publicKey } = await generateKeyPair('RS256', {
  extractable: true,
})

// Store the private key as PEM for the token-signing script.
const privateKeyPem = await exportPKCS8(privateKey)

// Add public metadata so verifiers can select this signing key.
const publicKeyJwk = await exportJWK(publicKey)
publicKeyJwk.kid = 'logout-key-1'
publicKeyJwk.use = 'sig'
publicKeyJwk.alg = 'RS256'

// Keep both test keys in one dedicated directory.
await mkdir('keys', { recursive: true })
await writeFile('keys/logout-private-key.pem', privateKeyPem)
await writeFile('keys/logout-public-key.jwk', JSON.stringify(publicKeyJwk, null, 2))

console.log('Created keys/logout-private-key.pem')
console.log('Created keys/logout-public-key.jwk')
Serve the public key as JWKS

A JWKS response wraps one or more public keys in a keys array. Verifiers fetch that array to find the key whose kid matches the token.

  • Create jwks-server.js inside the src folder.
  • Add the local JWKS server to src/jwks-server.js by pasting this code:
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'

const port = 3000
const jwksPath = '/.well-known/jwks.json'

// Load only the public key for the JWKS response.
const publicKey = JSON.parse(
  await readFile('keys/logout-public-key.jwk', 'utf8'),
)

const server = createServer((request, response) => {
  if (request.method === 'GET' && request.url === jwksPath) {
    response.writeHead(200, { 'Content-Type': 'application/json' })
    response.end(JSON.stringify({ keys: [publicKey] }, null, 2))
    return
  }

  response.writeHead(404, { 'Content-Type': 'application/json' })
  response.end(JSON.stringify({ error: 'Not found' }))
})

// Keep the public key available to later verification steps.
server.listen(port, () => {
  console.log('JWKS server running at http://localhost:3000/.well-known/jwks.json')
})

What does this code do?

  • The server reads keys/logout-public-key.jwk when it starts.
  • A matching GET request receives a JSON response containing the public key.
  • Every other path receives a 404 response.
  • The private PEM file never enters the JWKS response.

The server keeps the current terminal busy while it listens on port 3000. You can stop it later with Ctrl+C.

  • Save src/jwks-server.js.

Before you run the server, picture what the JWKS URL should return.

  • Start the local JWKS server by running this command:
npm run jwks

What should I see?

Your terminal should report that the JWKS server is running at http://localhost:3000/.well-known/jwks.json.

The command stays active because the server is waiting for local HTTP requests.

  • Open a new browser tab.
  • Visit http://localhost:3000/.well-known/jwks.json.

You should see a keys array containing one public key object. That object should include "kid": "logout-key-1".

You should also see "alg": "RS256" plus the RSA public-key fields. Your private key should not appear anywhere on the page.

You now have a live trust source: later verification code can fetch this public key without gaining access to the signing key.

JWKS page not loading?

  • Confirm that the terminal running npm run jwks remains open.
  • Confirm that keys/logout-public-key.jwk exists inside the project folder.
  • Stop any other local process that is already using port 3000.

Help me debug my local JWKS server.

✔️ Awesome, I've got everything!

Your JWKS server file is complete. Keep the server running while you continue to the token-signing step.

ⓧ I'd like to double check the full code

import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'

const port = 3000
const jwksPath = '/.well-known/jwks.json'

// Load only the public key for the JWKS response.
const publicKey = JSON.parse(
  await readFile('keys/logout-public-key.jwk', 'utf8'),
)

const server = createServer((request, response) => {
  if (request.method === 'GET' && request.url === jwksPath) {
    response.writeHead(200, { 'Content-Type': 'application/json' })
    response.end(JSON.stringify({ keys: [publicKey] }, null, 2))
    return
  }

  response.writeHead(404, { 'Content-Type': 'application/json' })
  response.end(JSON.stringify({ error: 'Not found' }))
})

// Keep the public key available to later verification steps.
server.listen(port, () => {
  console.log('JWKS server running at http://localhost:3000/.well-known/jwks.json')
})

Your public key is now available through a local JWKS endpoint. Next, you will use the matching private key to sign your first logout token.

Sign a Logout Token

Your local JWKS endpoint now publishes the public half of your test keypair. The matching private key can now play the identity provider role by signing a logout token.

A logout endpoint needs a trustworthy message with a known issuer. In this step, you will use jose to sign a JWT that identifies the user session through the required logout claims.

In this step, get ready to:
  • Create a script that loads your local signing key.
  • Add the claims required for a logout token.
  • Run the signer to print a compact token.
Create the signing script

A signed JWT combines a payload with a protected header. The payload states who issued the token plus which session the receiving app should end.

What belongs in a logout token?

  • The iss claim names the issuer. The aud claim names the intended application.
  • The sub claim identifies the user. The sid claim identifies a specific session.
  • The jti claim gives the token a unique identifier. The exp claim limits how long the token remains valid.
  • The events claim contains http://schemas.openid.net/event/backchannel-logout to declare the token's purpose.
  • Return to the file sidebar in your project editor.
  • Create a scripts folder beside src.

You should now see scripts beside src in the project file tree.

  • Create sign-logout-token.js inside the scripts folder.
  • Add the signing logic by pasting this code into scripts/sign-logout-token.js:
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { importPKCS8, SignJWT } from 'jose';

// Load the test key plus the matching public key ID.
const privateKeyPem = await readFile(new URL('../keys/logout-private-key.pem', import.meta.url), 'utf8');
const publicJwk = JSON.parse(await readFile(new URL('../keys/logout-public-key.jwk', import.meta.url), 'utf8'));
const privateKey = await importPKCS8(privateKeyPem, 'RS256');

// Build the logout event plus the claims that identify its target.
const logoutToken = await new SignJWT({
  sid: 'session-abc',
  events: {
    'http://schemas.openid.net/event/backchannel-logout': {},
  },
})
  .setProtectedHeader({ alg: 'RS256', kid: publicJwk.kid })
  .setIssuer('http://localhost:3000')
  .setAudience('local-client-id')
  .setSubject('user-123')
  .setIssuedAt()
  .setExpirationTime('2h')
  .setJti(randomUUID())
  .sign(privateKey);

// Print the compact token for the endpoint tests.
console.log(logoutToken);

What does this code do?

  • The two readFile() calls load the private PEM plus the public JWK. The public JWK supplies the matching kid.
  • The importPKCS8() call converts the PEM text into a signing key for SignJWT.
  • The payload identifies session-abc as the target session. The event URI declares the token's logout purpose.
  • The setter chain adds the standard claims. The randomUUID() call gives each run a fresh jti.
  • The sign() call applies the RS256 signature with your local private key.
  • Save scripts/sign-logout-token.js.
  • Confirm the file sidebar lists sign-logout-token.js inside scripts.

Can't find the key files?

Check that scripts sits beside keys in the project file tree. The relative paths move up from the script before entering the keys folder.

Ask for help with the file paths in your signing script.

✔️ Awesome, I've got everything!

Your signing script matches the target file.

ⓧ I'd like to double check the full code

import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { importPKCS8, SignJWT } from 'jose';

// Load the test key plus the matching public key ID.
const privateKeyPem = await readFile(new URL('../keys/logout-private-key.pem', import.meta.url), 'utf8');
const publicJwk = JSON.parse(await readFile(new URL('../keys/logout-public-key.jwk', import.meta.url), 'utf8'));
const privateKey = await importPKCS8(privateKeyPem, 'RS256');

// Build the logout event plus the claims that identify its target.
const logoutToken = await new SignJWT({
  sid: 'session-abc',
  events: {
    'http://schemas.openid.net/event/backchannel-logout': {},
  },
})
  .setProtectedHeader({ alg: 'RS256', kid: publicJwk.kid })
  .setIssuer('http://localhost:3000')
  .setAudience('local-client-id')
  .setSubject('user-123')
  .setIssuedAt()
  .setExpirationTime('2h')
  .setJti(randomUUID())
  .sign(privateKey);

// Print the compact token for the endpoint tests.
console.log(logoutToken);
Add the npm signer command

An npm script gives the signer a stable project command. It also records the new signer alongside the server commands already stored in package.json.

  • Add the logout-token signer to package.json by running this command in a second terminal for the same project:
npm pkg set scripts.sign-logout-token="node scripts/sign-logout-token.js"

What does this command do?

The command adds sign-logout-token to the existing scripts object in package.json.

Running that script tells Node.js to execute scripts/sign-logout-token.js from the project folder.

  • Open package.json from the project file sidebar.
  • Confirm the scripts object includes sign-logout-token.
  • Confirm the new entry points to scripts/sign-logout-token.js.

You should see the existing server scripts plus the new signer entry.

Signer entry missing?

Make sure the terminal is inside the folder containing package.json. Running the command from another folder updates the wrong project or fails to find a package manifest.

Help me add the npm signer script in the correct project folder.

Run the signer

Each signing run creates a fresh jti before applying the private-key signature. The terminal output becomes the token you send to the logout endpoint in the next step.

Before you run the signer, what shape do you expect a compact JWT to have?

  • Generate the logout token by running this command:
npm run sign-logout-token

What happens when the signer runs?

npm starts scripts/sign-logout-token.js. The script loads your local private key before signing the claims with RS256.

The final console.log() call prints the compact JWT for your endpoint tests.

You should see one long encoded token on a single terminal line. That output proves your local identity provider can mint a signed logout message.

Token not printed?

Check that keys/logout-private-key.pem contains the generated private key. Check that keys/logout-public-key.jwk contains valid JSON with a kid value.

Help me debug the logout-token signing script.

Great work. Your project can now create a signed logout token with the issuer plus session details the receiver needs.

Your signed logout token is ready. Next, you will build the endpoint that verifies it against your local JWKS.

Verify Logout Tokens

Your signing script now prints a compact JSON Web Token (JWT) with everything a logout request needs to identify a session.

A logout request needs cryptographic proof before your app can trust it. Your endpoint will fetch the public key from the JSON Web Key Set (JWKS) you served earlier.

In this step, get ready to:
  • Create an application endpoint that verifies the token against the local JWKS.
  • Start the application server on its own local port.
  • Confirm HTTP 200 by posting the valid token.
Build the verification endpoint

The jose library can resolve a public key from your local JWKS. Its verifier checks whether each token matches the trust rules your application expects.

  • In your code editor's file sidebar, create server.js inside the existing src folder.
  • Add the verification server by pasting this code into src/server.js:
import { createServer } from 'node:http'
import { createRemoteJWKSet, jwtVerify } from 'jose'
// Pin the identity provider values that every token must match.
const issuer = 'http://localhost:3000'
const audience = 'local-client-id'
const jwks = createRemoteJWKSet(new URL('http://localhost:3000/.well-known/jwks.json'))
// Receive the form body on the registered logout endpoint.
const server = createServer(async (request, response) => {
  if (request.method !== 'POST' || request.url !== '/backchannel-logout') {
    response.writeHead(404).end()
    return
  }
  let body = ''
  for await (const chunk of request) body += chunk
  const logoutToken = new URLSearchParams(body).get('logout_token')
  try {
    // Enforce the signature plus every pinned token expectation.
    await jwtVerify(logoutToken, jwks, {
      algorithms: ['RS256'],
      issuer,
      audience,
    })
    response.writeHead(200).end()
  } catch (error) {
    console.error(error.message)
    response.writeHead(400).end()
  }
})
// Start the application server on a port separate from the JWKS server.
server.listen(4000, () => console.log('Application server listening on http://localhost:4000'))

What does this code verify?

  • The issuer value identifies the trusted token source.
  • The audience value identifies the application that may receive the token.
  • createRemoteJWKSet() fetches the public key from your local JWKS URL.
  • algorithms restricts accepted signatures to RS256.
  • jwtVerify() verifies the token signature.
  • The verifier enforces the expected issuer.
  • The verifier enforces the expected audience.
  • The verifier rejects an expired exp claim.
  • The handler returns 200 after successful verification.
  • Save src/server.js.
  • Keep the JWKS server from earlier running in its terminal.
  • Create a new terminal panel for the application server.
  • Start the application server by running this command:
node src/server.js

What does this command do?

The node command executes src/server.js. The listener keeps this terminal busy while it waits for logout requests.

You'll see Application server listening on http://localhost:4000. That puts your logout receiver online beside the JWKS server.

Application server did not start?

  • Check that your terminal is inside the Node project containing src/server.js.
  • Compare the imports in src/server.js with the code above if the process exits immediately.
  • Stop any other local process using port 4000 before running the server again.

Help me diagnose why my Node application server exits before listening on port 4000.

✔️ Awesome, I've got everything!

Your saved src/server.js now contains the complete verification server.

ⓧ I'd like to double check the full code

import { createServer } from 'node:http'
import { createRemoteJWKSet, jwtVerify } from 'jose'
// Pin the identity provider values that every token must match.
const issuer = 'http://localhost:3000'
const audience = 'local-client-id'
const jwks = createRemoteJWKSet(new URL('http://localhost:3000/.well-known/jwks.json'))
// Receive the form body on the registered logout endpoint.
const server = createServer(async (request, response) => {
  if (request.method !== 'POST' || request.url !== '/backchannel-logout') {
    response.writeHead(404).end()
    return
  }
  let body = ''
  for await (const chunk of request) body += chunk
  const logoutToken = new URLSearchParams(body).get('logout_token')
  try {
    // Enforce the signature plus every pinned token expectation.
    await jwtVerify(logoutToken, jwks, {
      algorithms: ['RS256'],
      issuer,
      audience,
    })
    response.writeHead(200).end()
  } catch (error) {
    console.error(error.message)
    response.writeHead(400).end()
  }
})
// Start the application server on a port separate from the JWKS server.
server.listen(4000, () => console.log('Application server listening on http://localhost:4000'))
Post a valid logout token

An OpenID Connect back-channel logout request sends the token in a form-encoded HTTP POST. A successful response proves that the signature matches your public key.

  • Return to the terminal where your signing script printed the logout token.
  • Copy the complete compact token.
  • Replace your-logout-token-here in the command below with the copied token.

Before you send the request, picture the status code you expect to see.

  • Post the completed request to your endpoint by running this command:
curl -s -o /dev/null -w "%{http_code}\n" --data-urlencode "logout_token=your-logout-token-here" http://localhost:4000/backchannel-logout

What does this request do?

  • --data-urlencode sends the token in the required form field.
  • -s hides the transfer progress.
  • -o /dev/null discards the empty response body.
  • -w "%{http_code}\n" prints the response status.
  • http://localhost:4000/backchannel-logout targets the application endpoint you just started.

You'll see 200. You now have a receiver that accepts a correctly signed logout token from the trusted issuer.

Didn't get 200?

  • Confirm that the JWKS server is still running on port 3000.
  • Confirm that the application server is still running on port 4000.
  • Use the most recently printed logout token if the endpoint returns 400.
  • Mint a fresh token from the signing script when the earlier token has expired.
  • Copy only the compact JWT from the signer output.

Help me debug why my valid logout token does not return HTTP 200.

Your endpoint now accepts the valid logout token. Next up, you'll send broken tokens through the same verification path.

Reject Invalid Logout Tokens

Your endpoint already accepts a signed logout token. A valid request returns HTTP 200.

Signature verification proves who minted the token. OIDC Back-Channel Logout also requires payload checks before your app acts on it.

In this step, get ready to:
  • Create isolated token variants for the four invalid cases.
  • Add the remaining logout-token validation checks.
  • Verify every broken token against its matching server rejection.
Create four broken token variants

A reliable test changes one requirement at a time. Each broken token keeps the remaining valid claims intact so you can identify the exact reason for its rejection.

  • Switch back to scripts/sign-logout-token.js from the previous step.
  • Extend the script with a local selector covering the current valid token plus four broken variants.
  • Save scripts/sign-logout-token.js.

Four Isolated Failures

  • The missing identity variant removes both sub and sid.
  • The missing event variant removes http://schemas.openid.net/event/backchannel-logout from events.
  • The nonce variant adds a nonce claim.
  • The wrong algorithm variant signs with an algorithm outside RS256.
  • Return to the signing terminal from earlier.
  • Run the same signing command once for each broken test selection.

Each run should print one compact JWT. The payload or protected header changes according to the selected failure.

Do the variants look identical?

  • Check that the selector reaches a different signing path for each run.
  • Confirm that each path changes only the requirement assigned to that variant.

Help me debug the broken logout-token variants in my signing script.

Reject missing identity and logout events

A logout token needs a target through sub or sid. Its events object must also identify the request as a back-channel logout event.

  • Switch back to src/server.js from earlier.
  • Add a logout-claim validation block covering the identity requirement plus the event requirement.
  • Save src/server.js.

Why These Claims Matter

At least one of sub or sid identifies which user or session the request targets.

The required event value tells your endpoint that this signed token represents a back-channel logout request.

Before you test, do you think either broken token can still reach the successful response?

  • Use the signer selector to choose the variant with neither sub nor sid.
  • Run the signing command from the previous step.
  • Copy the printed token.
  • Return to the request terminal from earlier.
  • Replace the old logout token in the earlier curl request with the new token.
  • Post the request by rerunning that command.

You'll receive HTTP 400. The application server terminal shows a named reason for the missing identity.

  • Use the signer selector to choose the missing logout event variant.
  • Run the signing command from the previous step.
  • Copy the printed token.
  • Replace the token in the earlier curl request.
  • Post the request by rerunning that command.

You'll receive HTTP 400 again. This time the application server logs a named reason for the missing logout event.

Good progress. Your endpoint now refuses tokens that cannot identify a logout target or prove they represent a logout event.

Does Either Request Return 200?

  • Confirm that the claim checks run before the endpoint sends its successful response.
  • Check that the identity condition rejects only when both sub and sid are absent.
  • Confirm that the event check uses the exact http://schemas.openid.net/event/backchannel-logout value.

Help me find why an invalid logout token still returns HTTP 200.

Reject nonce and wrong algorithms

A logout token containing nonce violates the required token shape. Your existing alg restriction also keeps verification pinned to RS256.

  • Switch back to src/server.js.
  • Add the remaining rejection handling for a present nonce plus a signing algorithm outside RS256.
  • Save src/server.js.

Where These Rejections Happen

The payload check rejects nonce after the signature and standard claims pass verification.

The pinned algorithm rejects a different signing method during verification. Your error handling turns that failure into HTTP 400 with a named log reason.

Before you run the final tests, do you expect either token to pass every validation layer?

  • Use the signer selector to choose the nonce variant.
  • Run the signing command from the previous step.
  • Copy the printed token.
  • Return to the request terminal.
  • Replace the token in the earlier curl request.
  • Post the request by rerunning that command.

You'll receive HTTP 400. The application server logs a named reason for the forbidden nonce claim.

  • Use the signer selector to choose the wrong signing algorithm variant.
  • Run the signing command from the previous step.
  • Copy the printed token.
  • Replace the token in the earlier curl request.
  • Post the request by rerunning that command.

You'll receive HTTP 400. The application server logs a named reason for the disallowed signing algorithm.

That completes your validation boundary. All four malformed tokens now stop before the endpoint returns its successful response.

Missing a Named Rejection Reason?

  • Check that each logout-specific condition logs its own reason before returning HTTP 400.
  • Confirm that verification failures from a disallowed alg reach the named algorithm rejection path.
  • Keep the compact token text out of any screenshot if it makes the server logs hard to read.

Help me map each invalid logout token to a specific HTTP 400 log reason.

Next, you'll connect this guarded endpoint to a live session so an accepted logout token can end access from outside the browser.

End a Live Session

Your endpoint now rejects malformed logout tokens for specific reasons. A valid request still leaves the live session untouched.

This step connects token validation to an in-memory session store. You will also add a replay guard so each accepted token can end a session only once.

In this step, get ready to:
  • Create an in-memory session store with a login route.
  • Protect data with a session cookie.
  • Delete the active session while blocking token replay.
Create the session store

The session store links a browser session to the token's sid claim. The replay guard remembers each accepted jti value.

  • In src/server.js, add the following session state below the existing logout configuration:
// Keep local sessions and accepted token IDs in memory
const sessions = new Map();
const processedJtis = new Set();

// Read the session ID from the request cookie
function getSessionId(req) {
  const cookieHeader = req.headers.cookie ?? '';
  const sessionCookie = cookieHeader
    .split(';')
    .map((cookie) => cookie.trim())
    .find((cookie) => cookie.startsWith('session='));

  return sessionCookie?.split('=')[1];
}

// Remove the targeted session or every session for the subject
function destroySessions(payload) {
  if (payload.sid) {
    sessions.delete(payload.sid);
    return;
  }

  for (const [sid, session] of sessions) {
    if (session.sub === payload.sub) sessions.delete(sid);
  }
}

What does this code do?

  • The sessions map stores each active session under its sid value.
  • The processedJtis set records accepted logout token identifiers.
  • The getSessionId() helper reads the session cookie from an incoming request.
  • The destroySessions() helper removes a specific session when sid is present.
  • Save src/server.js.
  • Switch back to the application server terminal from earlier.
  • Stop the running server by pressing Ctrl+C.
  • Restart the application server by running this command:
node src/server.js

What should I see?

You should see Application server listening on http://localhost:3001. This confirms the new session helpers load without a syntax error.

Does the server fail to restart?

Check that both closing braces remain beneath destroySessions(). Confirm that sessions appears only once in src/server.js.

Help me fix the session helper code in my Node server

Add login and protected routes

A session cookie gives the client a session identifier. The protected route uses that identifier to look up active session data.

  • In the request handler inside src/server.js, add this login branch before the existing /backchannel-logout branch:
if (req.method === 'POST' && req.url === '/login') {
  // Create the fixed local session used by this project
  sessions.set('session-abc', { sub: 'user-123' });
  res.writeHead(200, {
    'Content-Type': 'application/json',
    'Set-Cookie': 'session=session-abc; HttpOnly; SameSite=Lax',
  });
  res.end(JSON.stringify({ message: 'Logged in' }));
  return;
}

How does login create a session?

The route stores user-123 under session-abc. Its response gives the client a cookie containing the same session identifier.

The HttpOnly attribute keeps browser scripts from reading the cookie. The SameSite=Lax attribute limits when the browser sends it across sites.

  • Save src/server.js.
  • Return to the application server terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the application server by running this command:
node src/server.js

What should I see?

You should see the application server listening on http://localhost:3001 again. The new login route is ready to receive a request.

  • Switch to a separate terminal from earlier.
  • Create the local session by running this command:
curl -i -c session-cookies.txt -X POST http://localhost:3001/login

What should I see?

You should see an HTTP 200 response with {"message":"Logged in"}. The response headers should include a cookie for session-abc.

The -c option saves the response cookie in session-cookies.txt. You can now reuse that cookie when requesting protected data.

Did login return a different response?

Confirm that the application server is listening on http://localhost:3001. Check that the login branch sits inside the server's request handler.

Help me debug the local login route

  • Add this protected-route branch below the login branch in src/server.js:
if (req.method === 'GET' && req.url === '/protected') {
  // Return data only when the cookie points to an active session
  const sessionId = getSessionId(req);
  const session = sessions.get(sessionId);

  if (!session) {
    sendJson(res, 401, { error: 'Unauthorized' });
    return;
  }

  sendJson(res, 200, { message: 'Protected data', user: session.sub });
  return;
}

How does the route protect data?

  • The route reads the session identifier from the request cookie.
  • The route looks for that identifier in the sessions map.
  • A missing session produces HTTP 401.
  • An active session produces the protected response for user-123.
  • Save src/server.js.
  • Return to the application server terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the application server by running this command:
node src/server.js

What should I see?

You should see the application server listening on http://localhost:3001. Restarting clears the in-memory session store.

  • Return to the separate terminal.
  • Create a fresh session by running the login request again:
curl -i -c session-cookies.txt -X POST http://localhost:3001/login

What should I see?

You should see HTTP 200 with {"message":"Logged in"}. The refreshed cookie file now points to an active session.

Before you test the protected route, what response do you expect from an active session?

  • Request the protected data with the saved cookie by running:
curl -i -b session-cookies.txt http://localhost:3001/protected

Your session is active

You should see HTTP 200 with {"message":"Protected data","user":"user-123"}. That response proves the cookie resolves to the live session-abc session.

Connect logout to session state

The OpenID Connect back-channel logout flow identifies the session through sid or the user through sub. Recording jti prevents the same signed request from being processed twice.

  • In the valid-token path inside src/server.js, replace the current success response with this replay check and session cleanup:
if (!payload.jti) throw new Error('Missing jti');
if (processedJtis.has(payload.jti)) {
  throw new Error('Logout token replayed');
}

// End the matching session only after every token check passes
 destroySessions(payload);
processedJtis.add(payload.jti);

console.log(`Logout accepted: ${payload.jti}`);
sendJson(res, 200, { message: 'Logout accepted' });

Why record the token identifier?

The session is removed only after signature checks and claim checks succeed. This keeps malformed requests from changing session state.

The accepted jti enters processedJtis immediately. A second request carrying that identifier reaches the replay error before it can perform logout work again.

  • Remove the extra space before destroySessions(payload); so it aligns with processedJtis.add(payload.jti);.
  • Save src/server.js.
  • Return to the application server terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the completed application server by running this command:
node src/server.js

What should I see?

You should see Application server listening on http://localhost:3001. The restarted server has an empty session store and an empty replay guard.

Seeing an error near payload?

Place the new code after jwtVerify() has produced payload. Keep it inside the existing try block so the endpoint returns HTTP 400 for a replay.

Help me place the replay guard inside my logout handler

  • Compare your completed src/server.js with the full reference if you want to check every route:

✔️ Awesome, I've got everything!

Your server now creates sessions, protects data, ends sessions from logout tokens, and rejects replays.

ⓧ I'd like to double check the full code

import http from 'node:http';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const issuer = 'http://localhost:3000';
const audience = 'local-client-id';
const logoutEvent = 'http://schemas.openid.net/event/backchannel-logout';
const jwks = createRemoteJWKSet(
  new URL('http://localhost:3000/.well-known/jwks.json'),
);

// Keep local sessions and accepted token IDs in memory
const sessions = new Map();
const processedJtis = new Set();

// Send a JSON response with a status code
function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(payload));
}

// Read form values from an incoming request
function readFormBody(req) {
  return new Promise((resolve, reject) => {
    let body = '';
    req.on('data', (chunk) => {
      body += chunk;
    });
    req.on('end', () => resolve(new URLSearchParams(body)));
    req.on('error', reject);
  });
}

// Read the session ID from the request cookie
function getSessionId(req) {
  const cookieHeader = req.headers.cookie ?? '';
  const sessionCookie = cookieHeader
    .split(';')
    .map((cookie) => cookie.trim())
    .find((cookie) => cookie.startsWith('session='));

  return sessionCookie?.split('=')[1];
}

// Remove the targeted session or every session for the subject
function destroySessions(payload) {
  if (payload.sid) {
    sessions.delete(payload.sid);
    return;
  }

  for (const [sid, session] of sessions) {
    if (session.sub === payload.sub) sessions.delete(sid);
  }
}

// Verify the logout token before changing session state
async function handleLogout(req, res) {
  try {
    const form = await readFormBody(req);
    const token = form.get('logout_token');
    if (!token) throw new Error('Missing logout_token');

    const { payload } = await jwtVerify(token, jwks, {
      algorithms: ['RS256'],
      issuer,
      audience,
    });

    if (!payload.sub && !payload.sid) {
      throw new Error('Missing sub or sid');
    }
    if (!payload.events?.[logoutEvent]) {
      throw new Error('Missing logout event');
    }
    if ('nonce' in payload) throw new Error('Nonce is not allowed');
    if (!payload.jti) throw new Error('Missing jti');
    if (processedJtis.has(payload.jti)) {
      throw new Error('Logout token replayed');
    }

    // End the matching session only after every token check passes
    destroySessions(payload);
    processedJtis.add(payload.jti);

    console.log(`Logout accepted: ${payload.jti}`);
    sendJson(res, 200, { message: 'Logout accepted' });
  } catch (error) {
    const reason = error.code === 'ERR_JOSE_ALG_NOT_ALLOWED'
      ? 'Signing algorithm must be RS256'
      : error.message;
    console.error(`Logout rejected: ${reason}`);
    sendJson(res, 400, { error: reason });
  }
}

const server = http.createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/login') {
    // Create the fixed local session used by this project
    sessions.set('session-abc', { sub: 'user-123' });
    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Set-Cookie': 'session=session-abc; HttpOnly; SameSite=Lax',
    });
    res.end(JSON.stringify({ message: 'Logged in' }));
    return;
  }

  if (req.method === 'GET' && req.url === '/protected') {
    // Return data only when the cookie points to an active session
    const sessionId = getSessionId(req);
    const session = sessions.get(sessionId);

    if (!session) {
      sendJson(res, 401, { error: 'Unauthorized' });
      return;
    }

    sendJson(res, 200, { message: 'Protected data', user: session.sub });
    return;
  }

  if (req.method === 'POST' && req.url === '/backchannel-logout') {
    await handleLogout(req, res);
    return;
  }

  sendJson(res, 404, { error: 'Not found' });
});

server.listen(3001, () => {
  console.log('Application server listening on http://localhost:3001');
});

How to use this reference

Compare the route order and helper placement with your file. Keep the values aligned with the local issuer, client, user, and session from the earlier steps.

The final test follows one session from creation to termination. You will reuse one signed logout token so the replay guard sees the same jti twice.

  • Return to the separate terminal.
  • Create a fresh session by running:
curl -i -c session-cookies.txt -X POST http://localhost:3001/login

What should I see?

You should see HTTP 200 with {"message":"Logged in"}. The server now holds session-abc for user-123.

  • Confirm the session can read protected data by running:
curl -i -b session-cookies.txt http://localhost:3001/protected

What should I see?

You should see HTTP 200 with the protected data response. This is the live session that the logout token is about to end.

  • Create one valid logout token by running the signing script:
node scripts/sign-logout-token.js

What should I see?

You should see one compact token made of three dot-separated sections. Keep this exact token available for both logout requests.

  • Copy the complete token from the terminal output.
  • Replace paste-your-logout-token-here with that token in the command below.

Before you post the token, do you expect the active session to survive or disappear?

  • Send the valid logout token to the application server by running:
curl -i -d "logout_token=paste-your-logout-token-here" http://localhost:3001/backchannel-logout

The logout request was accepted

You should see HTTP 200 with {"message":"Logout accepted"}. The application server terminal should log an accepted jti value.

The request body uses the official logout_token form parameter. The endpoint removes session-abc before returning success.

Before you request the protected route again, what should happen when the cookie points to the deleted session?

  • Request the protected data again with the same cookie by running:
curl -i -b session-cookies.txt http://localhost:3001/protected

The live session is gone

You should see HTTP 401 with {"error":"Unauthorized"}. The user never touched a browser logout control, yet the protected route now refuses the old session.

Before you repeat the logout request, do you think the endpoint will accept the same signed token twice?

  • Run the exact logout command again with the same token:
curl -i -d "logout_token=paste-your-logout-token-here" http://localhost:3001/backchannel-logout

The replay guard worked

You should see HTTP 400 with {"error":"Logout token replayed"}. The application server terminal should log the same named rejection reason.

That second response proves the token's jti remains recorded after the first logout. Replaying a valid signature no longer repeats a trusted action.

Did the protected route stay active?

Confirm that the signed token contains sid set to session-abc. Check that destroySessions(payload) runs before the HTTP 200 response.

Help me debug why back-channel logout did not delete my session

You made the complete flow work. A signed provider request now ends a live session from outside the client, while the replay guard blocks the same token from being trusted twice.

Secret mission

Build a Logout Security Evidence Matrix

Turn your completed logout flow into a repeatable security acceptance test. You will capture the response from every valid, invalid, and replayed request. Your final evidence will show why each request was accepted or rejected.

Clean Up Your Resources

Clean Up Your Resources

This Node.js project runs entirely on your machine. There are no cloud charges while you choose a cleanup option below.

Resources you used:

  • Local JWKS server process serving http://localhost:3000/.well-known/jwks.json.
  • Local application server process holding the in-memory session store plus replay guard.
  • Local RSA test signing key at keys/logout-private-key.pem.
  • Local public key at keys/logout-public-key.jwk.

Keep everything running

No action needed. Choose this while you are still testing provider-initiated logout locally.

  • Keep both local server processes running for more endpoint tests.
  • Keep the RSA test keypair for local signing only.
  • Retain src/server.js as your reusable logout endpoint implementation.
  • Record that a real identity provider replaces the local issuer plus JWKS URL in your project documentation.

Pause - I'll come back to this later

Stopping the two servers frees their local ports. Your source files remain ready for another testing session.

  • Return to the terminal panel running the local JWKS server from earlier.
  • Press Ctrl+C to stop it.
  • Return to the terminal panel running the local application server from earlier.
  • Press Ctrl+C to stop it.
  • Keep the project files in their current local location.

You have reached a clean pause point. Both local servers are stopped.

Delete - I don't want to use this again

Remove the disposable signing keypair when you finish local token tests. Keep src/server.js as the reusable endpoint implementation.

Delete Only the Test Keys

Deleting the keypair is permanent. This is the safe choice before the project moves toward a real deployment.

  • Return to the terminal panel running the local JWKS server from earlier.
  • Press Ctrl+C to stop it.
  • Return to the terminal panel running the local application server from earlier.
  • Press Ctrl+C to stop it.
  • Use your operating system's file browser to return to the local Node project from earlier.
  • Open keys inside that project.
  • Delete keys/logout-private-key.pem.
  • Delete keys/logout-public-key.jwk.
  • Confirm that neither key file appears in the keys folder.

The keys folder should no longer list either key file. That removes the test signing material before it can reach a real deployment.

Nice Work!

Nice Work!

Excellent work! You built a local OIDC back-channel logout flow that ends a live session after a server receives a signed token. You also proved that invalid tokens and replayed tokens are rejected for named reasons.

You've learned how to:

  • Sign an RS256 logout token locally. Publish the matching public key through a JWKS endpoint. Accept a valid token with HTTP 200.
  • Apply logout token validation to the signature and required claims. Reject each broken token with HTTP 400. Log a named reason for every failure.
  • End a live application session through POST /backchannel-logout. Return HTTP 401 from GET /protected after logout. Refuse a reused jti through a replay guard.
  • Secret Mission: Complete an optional challenge that extends the working logout flow.

Ready to quiz yourself?