An email arrives at the Aroma Store team. CataBox, a third-party application that helps coffee enthusiasts keep a tasting notebook, wants to offer its users the ability to import automatically the coffees they have bought from Aroma. The technical request is simple: "can we read one of your customers' orders?".

With the authentication we built in 03-06 there is only one possible answer, and it is a bad one: that the customer gives CataBox their Aroma Store email address and password, and that CataBox calls POST /v1/sessions pretending to be them. That means a company you do not control is storing your customers' passwords, that the token it obtains has every permission — including creating orders and changing the delivery address — that you cannot revoke its access without forcing the customer to change their password, and that you have no way of telling which requests come from the customer and which from CataBox.

OAuth 2.0 exists for exactly this. This lesson explains what problem it solves, how it is put together, which flows are used today and which are discouraged, how OpenID Connect adds the piece it lacks on top — identity — and how a token from an external authorisation server is validated in our API. By the end, src/middleware/oauth-authentication.js will exist and will coexist with authenticate from 03-06.

Warning. OAuth 2.0 is a security protocol and its details matter: one badly validated parameter turns a correct flow into an account takeover. The code in this lesson is didactic and must be reviewed by a security professional before any real deployment. All the data, domains and credentials are fictional.

Contents

  1. The problem: delegating access without sharing credentials
  2. The four roles of OAuth 2.0
  3. Tokens: access, refresh and scopes
  4. Opaque tokens versus JWTs: introspection or local validation
  5. Authorization Code + PKCE
  6. Why the implicit and password flows are discouraged
  7. Client Credentials: SwiftShip, machine to machine
  8. Refresh Token and Device Code
  9. state, CSRF and the authorisation request's parameters
  10. OpenID Connect: identity on top of OAuth
  11. Validating the token in the API: JWKS, kid and the claims
  12. The authenticateOAuth middleware and its coexistence with authenticate
  13. Scopes and roles combined
  14. Client registration, secrets and redirect_uri
  15. Revocation and logout
  16. Why you should not implement your own authorisation server
  17. OAuth errors and their translation into the Aroma catalogue

  1. The problem: delegating access without sharing credentials

Before OAuth, the usual pattern was called the shared password antipattern, and it looked like this:

Shared password OAuth 2.0
What CataBox stores The customer's email address and password A token that expires
What it can do Everything the customer can do Only what was authorised (orders.read)
How long it lasts For ever Minutes or hours, with a revocable refresh
How it is revoked By changing the password (and breaking everything else) One click in "connected applications"
Auditing Impossible to tell customer from application Every request identifies the OAuth client
Second factor Incompatible: CataBox cannot pass it Compatible: the authorisation server handles it
If CataBox suffers a breach Your customers' passwords are out in the wild The tokens are revoked and that is that

The central idea of OAuth 2.0 fits in one sentence: the client never sees the credentials; the user authenticates at a trusted site and what comes back is a bounded, revocable permission.

And one clarification that avoids 90% of the initial confusion: OAuth 2.0 is a delegated authorisation protocol, not an authentication one. It is not there for "sign in with", however much it gets used that way everywhere. What is there for that is OpenID Connect, which is built on top of it (section 10).

  1. The four roles of OAuth 2.0

Role Name in the standard Who it is in our case
Resource owner Resource Owner Marta García (cus_842), the person who owns the orders
Client Client CataBox, the application that wants access. Also the SPA and Aroma Mobile
Authorisation server Authorization Server (AS) https://auth.aromastore.example — authenticates and issues tokens
Resource server Resource Server (RS) https://api.aromastore.example/v1our API

The most important practical consequence for this course: our API is only the resource server. It shows no login screens, manages no passwords, issues no tokens and knows nothing about consent. Its only job in OAuth is:

  1. Receive an Authorization: Bearer <token>.
  2. Verify that the token is genuine, current and addressed to it.
  3. Extract who the subject is and which scopes they have.
  4. Decide whether that combination may do what it is asking for.

That is all. Nothing else in this lesson is implemented inside our API, and that separation is precisely the protocol's value.

graph LR
  U[Marta - resource owner] -->|1. authorises| AS[auth.aromastore.example<br/>Authorization server]
  C[CataBox - client] -->|2. asks for a token| AS
  AS -->|3. issues an access token| C
  C -->|4. Bearer token| RS[api.aromastore.example<br/>Resource server: OUR API]
  RS -->|5. verifies the signature with JWKS| AS
  RS -->|6. authorised data| C

Notice step 5: the API does not ask the authorisation server on every request. It downloads its public keys once, caches them and verifies locally. Why, in section 4.

  1. Tokens: access, refresh and scopes

Access token and refresh token

Access token Refresh token
What it is for Accessing the API Obtaining a new access token
Who it is presented to The resource server (our API) Only the authorisation server
Typical lifetime 5–60 minutes Days or months
Sent in Authorization: Bearer The body of POST /token
If it is stolen Damage bounded by its lifetime Serious: prolonged access
Our API sees it Yes, on every request Never

The reason there are two is a compromise between security and usability: you want the token that travels constantly over the network to expire soon, but you do not want to ask the user for their password every fifteen minutes. The refresh token travels rarely, is stored better and can be revoked centrally.

This architecture is the same one we already built by hand in 03-06 with our own access and refresh tokens. The difference is who issues them: there our API did; here a specialised authorisation server does, and our API only verifies them.

Scopes

A scope is a label that bounds what a token allows. Scopes are requested in the authorisation request, the user sees them on the consent screen, and they travel inside the token.

Aroma Store's scopes:

Scope Allows Who asks for it Consent text
coffees.read Browsing the catalogue Everyone "See the coffee catalogue"
orders.read Reading the user's orders CataBox, SPA, Aroma Mobile "See your order history"
orders.write Creating and paying for orders SPA, Aroma Mobile "Create orders on your behalf"
reviews.write Publishing reviews SPA, Aroma Mobile "Publish reviews under your name"
reviews.moderate Approving or rejecting reviews Internal back office "Moderate the shop's reviews"
shipments.write Marking orders as shipped SwiftShip (no consent: a machine)

Four scope design rules that avoid most of the problems:

  1. resource.action granularity, not one scope per endpoint. With 24 URIs, one scope per endpoint produces an unreadable consent screen.
  2. Always separate read from write. CataBox asks for orders.read and never orders.write. That separation is 80% of the value.
  3. A scope bounds, it does not grant. A token having orders.read does not mean it can read all orders: it means it can read the orders of the token's subject. The ownership check (BOLA, 04-02) is still mandatory.
  4. Write the text the user will see alongside the scope. If you cannot explain it in one comprehensible line, the scope is badly designed.

  1. Opaque tokens versus JWTs: introspection or local validation

The access token can be one of two natures, and the choice directly affects how our API validates it.

Opaque token JWT (self-contained)
What it is A random string: a7f3c9... Three Base64 parts with the claims inside
Who knows what it means Only the authorisation server Anyone who verifies it
How the API validates it Introspection: POST /introspect to the AS Locally: it verifies the signature
Latency per request One extra network call Zero
Immediate revocation Yes No: valid until its exp
Content disclosure Reveals nothing The payload is readable (it is not encrypted!)
Coupling The API depends on the AS at run time The API only needs the public keys

Introspection (RFC 7662) looks like this:

POST /introspect HTTP/1.1
Host: auth.aromastore.example
Authorization: Basic <resource server credentials>
Content-Type: application/x-www-form-urlencoded

token=a7f3c9d2e8b1...
{
  "active": true,
  "sub": "cus_842",
  "scope": "coffees.read orders.read",
  "client_id": "catabox",
  "exp": 1786000000
}

The decisive field is active: if it is false, the token is worthless, with no further explanation.

What Aroma Store chooses: JWTs with local validation, for three reasons. Latency matters (one extra call per request doubles the catalogue's response time); availability matters (if the AS goes down, with introspection the whole API goes down with it); and we get immediate revocation another way, with short-lived tokens (15 minutes) and refresh token revocation.

The trade-off: a revoked JWT access token remains valid until it expires. If that is unacceptable for some operation — cancelling an order, changing a password — introspection is used for those operations only, or a revocation list in Redis is consulted. It is a per-endpoint decision, not a global one.

And a reminder that never hurts: a JWT is signed, not encrypted. Anyone who intercepts the token reads its content with a Base64 decoder. Never put sensitive data in the claims.

  1. Authorization Code + PKCE

This is the flow. If you remember only one thing from this lesson, make it this: it works for SPAs, for mobile applications, for server applications and for third-party applications such as CataBox.

PKCE (Proof Key for Code Exchange, pronounced "pixy") is the extension that makes it secure for clients that cannot keep a secret. It is now considered mandatory for all clients, confidential ones included.

sequenceDiagram
  participant U as Marta (browser)
  participant C as CataBox
  participant AS as auth.aromastore.example
  participant RS as api.aromastore.example

  C->>C: 1. Generates a random code_verifier
  C->>C: 2. code_challenge = BASE64URL(SHA256(verifier))
  C->>U: 3. Redirects to /authorize with code_challenge and state
  U->>AS: 4. GET /authorize?...
  AS->>U: 5. Login screen (password + 2FA)
  U->>AS: 6. Credentials
  AS->>U: 7. Consent screen: "CataBox wants to see your orders"
  U->>AS: 8. Accepts
  AS->>U: 9. Redirects to redirect_uri?code=xyz&state=...
  U->>C: 10. Delivers the code
  C->>AS: 11. POST /token with code + code_verifier
  AS->>AS: 12. Checks SHA256(verifier) == challenge
  AS->>C: 13. access_token + refresh_token
  C->>RS: 14. GET /v1/orders with Bearer
  RS->>RS: 15. Verifies signature (JWKS), iss, aud, exp, scope
  RS->>C: 16. 200 with Martas orders

Let us look at the real requests.

Steps 3-4: the authorisation request. It happens in the user's browser, not on CataBox's server:

GET /authorize
  ?response_type=code
  &client_id=catabox
  &redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
  &scope=coffees.read%20orders.read
  &state=xY9fK2mQ7pL1
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: auth.aromastore.example

Step 9: the redirect back. The code arrives in the URL, and it is single-use with a very short life (typically 30–60 seconds):

HTTP/1.1 302 Found
Location: https://catabox.example/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xY9fK2mQ7pL1

Step 11: exchanging the code for the token. This is a server-to-server request (or one from the app), never a redirect:

POST /token HTTP/1.1
Host: auth.aromastore.example
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fcatabox.example%2Fcallback
&client_id=catabox
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFyb21hLTIwMjYtMDgifQ...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "def50200a1b2c3...",
  "scope": "coffees.read orders.read"
}

What PKCE solves exactly

The authorisation code travels through the browser's URL, and that is not a secure channel: it stays in the history, it can appear in a proxy's logs and, on mobile, another application can register the same URI scheme and steal the redirect. Before PKCE, an attacker who captured the code could exchange it for a token, because the AS had no way of knowing that whoever was exchanging it was not whoever had requested it.

PKCE binds the two requests with a single-use secret:

// Client (SPA or mobile app). This runs BEFORE redirecting the user.
import crypto from 'node:crypto';

function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')      // Base64URL: + → -
    .replace(/\//g, '_')      //             / → _
    .replace(/=+$/, '');      //  no padding
}

// 1. A random 32-byte secret, different on every login attempt.
const codeVerifier = base64url(crypto.randomBytes(32));

// 2. Its hash: this is the ONLY thing that travels through the browser's URL.
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());

// 3. The verifier is stored locally (sessionStorage in the SPA) and is NOT sent yet.
sessionStorage.setItem('pkce_verifier', codeVerifier);

// 4. Only at step 11, together with the code, is the original verifier sent.
//    The AS computes SHA256(verifier) and compares it with the challenge it received at step 4.

The cryptographic property that makes it work: SHA-256 cannot be reversed. An attacker who sees the code_challenge in the URL cannot deduce the code_verifier, so even if they steal the code they cannot exchange it. And code_challenge_method must always be S256; the value plain exists for compatibility and protects against nothing.

  1. Why the implicit and password flows are discouraged

You will find them in old tutorials. The current OAuth 2.0 best practices (and OAuth 2.1) retire them.

Flow How it worked Why it is retired What to use
Implicit (response_type=token) The AS returned the access token directly in the URL fragment The token ends up in the history, in the Referer and exposed to any script on the page; no secure refresh token Authorization Code + PKCE
Resource owner password (password) The app asks for username and password and sends them to the AS It reintroduces the very antipattern OAuth came to eliminate; incompatible with 2FA and with federated login Authorization Code + PKCE

The password flow deserves a nuance because it raises legitimate questions: it was created for first-party applications, that is, your own. But even there it is a bad idea, because your own app ends up handling passwords, cannot go through a second factor and benefits from none of the AS's infrastructure. The Aroma Store SPA and Aroma Mobile use Authorization Code + PKCE just like CataBox, even though they are ours. The difference between a first-party and a third-party app is that the first-party one can skip the consent screen, not that it uses a different flow.

  1. Client Credentials: SwiftShip, machine to machine

When no user is involved, there is nobody to ask for consent. SwiftShip is a system that acts in its own name to mark orders as shipped.

sequenceDiagram
  participant R as SwiftShip (backend)
  participant AS as auth.aromastore.example
  participant RS as api.aromastore.example

  R->>AS: POST /token (grant_type=client_credentials + client_secret)
  AS->>R: access_token (scope=shipments.write, 1 h)
  R->>RS: POST /v1/orders/ord_5001/shipment (Bearer)
  RS->>RS: Verifies signature, aud, scope=shipments.write
  RS->>R: 200
POST /token HTTP/1.1
Host: auth.aromastore.example
Authorization: Basic c3dpZnRzaGlwOmZpY3Rpb25hbFNlY3JldA==
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=shipments.write

Three characteristics distinguish it:

  • There is no redirect_uri, no code and no consent: only the client and its secret.
  • There is no refresh token: when the access token expires, another is requested. It is cheap because it requires nobody in front of a screen.
  • The token's sub is the client itself (swiftship), not a person. In our API that corresponds to the partner role, and it means that the per-customer ownership checks do not apply: authorisation has to come from somewhere else (which orders SwiftShip may touch and in which states).

A client using this flow is confidential by definition: it keeps a secret on a server. It is never used in a SPA or in a mobile app, because anyone can extract the secret from the downloaded code.

  1. Refresh Token and Device Code

Refresh Token

POST /token HTTP/1.1
Host: auth.aromastore.example
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=def50200a1b2c3...
&client_id=catabox

The response is a new access token and, in modern implementations, a new refresh token as well. That is called refresh token rotation and it is an important defence: if a refresh token is used twice, the AS knows that one of the two uses is an attacker's — because the legitimate one already received its replacement — and revokes the whole token family, closing the session. That is reuse detection, and it is worth demanding when choosing a provider.

Where the refresh token is stored, by type of client:

Client Storage Risk
Backend (CataBox) An encrypted database Low
Mobile app The system keychain (Keychain / Keystore) Low
SPA An HttpOnly+Secure+SameSite cookie, managed by a backend of your own Medium
SPA localStorage High: any XSS steals it

For SPAs, the current recommendation is the BFF pattern (Backend For Frontend): a small backend of your own holds the tokens and the SPA talks to it using a session cookie. If that is not viable, use rotating refresh tokens with a short life and never localStorage.

Device Code

For devices with no keyboard and no browser — a screen in the coffee shop showing the stock, a television. The device asks for a code, shows the user "go to aromastore.example/activate and enter KDLF-9XZQ", and meanwhile polls the AS until the user completes the authorisation on their phone. It is mentioned for completeness; it does not apply to Aroma Store's current consumers.

  1. state, CSRF and the authorisation request's parameters

Parameter Mandatory What it is Risk if omitted or not validated
response_type Yes code
client_id Yes The client's public identifier
redirect_uri Yes (recommended) Where to come back to Open redirect if the AS does not compare it exactly
scope Recommended The permissions requested The registered ones are applied
state Yes An opaque random value Login CSRF
code_challenge Yes The verifier's hash Code theft
code_challenge_method Yes S256 plain protects nothing
nonce Yes in OIDC Random, comes back in the id_token id_token replay
prompt No login, consent, none

Which attack state prevents. Without it, an attacker starts the flow with their Aroma Store account, captures the code from the redirect and tricks the victim into having their browser visit https://catabox.example/callback?code=<the attacker's>. CataBox exchanges that code and associates the attacker's Aroma Store account with the victim's CataBox session. From then on, everything the victim saves in CataBox goes into an account the attacker controls.

The defence is a random value bound to the browser session:

// Before redirecting: it is generated and stored bound to the session.
const state = base64url(crypto.randomBytes(16));
sessionStorage.setItem('oauth_state', state);
// ... redirect to /authorize?...&state=<state>

// In the callback: the comparison is MANDATORY before exchanging the code.
const received = new URLSearchParams(location.search).get('state');
const expected = sessionStorage.getItem('oauth_state');
if (!received || received !== expected) {
  throw new Error('state does not match: possible CSRF. The code is not exchanged.');
}
sessionStorage.removeItem('oauth_state');   // single use

With PKCE properly implemented the risk drops a great deal, but state is still mandatory: it protects against a different attack (fixation of the client's session, not code theft) and it also serves to remember where to return to inside the application.

  1. OpenID Connect: identity on top of OAuth

OAuth 2.0 answers "may this application do this?". It does not answer "who is the user?". Using an access token as proof of identity is a classic and dangerous mistake: the token says nothing verifiable about who obtained it or which application it was issued for, and that is why token substitution attacks existed.

OpenID Connect (OIDC) is a thin, standardised layer on top of OAuth 2.0 that adds exactly what is missing.

OAuth 2.0 OpenID Connect
Question What can it do? Who is it?
Key scope orders.read openid
Returns access_token access_token + id_token
Format of the result Free The id_token is always a JWT
Intended recipient The resource server The client
Discovery /.well-known/openid-configuration

Adding openid to scope is enough to turn the flow into OIDC:

scope=openid profile email coffees.read orders.read

The decoded id_token:

{
  "iss": "https://auth.aromastore.example",
  "sub": "cus_842",
  "aud": "catabox",
  "exp": 1786003600,
  "iat": 1786000000,
  "nonce": "n-0S6_WzA2Mj",
  "auth_time": 1785999950,
  "name": "Marta García",
  "email": "[email protected]",
  "email_verified": true,
  "locale": "en-GB"
}

The most used standard claims:

Claim Meaning
sub The user's stable and unique identifier at that issuer. The real key
iss Who issued the token
aud Which client it was issued for
nonce The random value the client sent: prevents replaying an old id_token
auth_time When they actually authenticated (useful for requiring reauthentication)
name, email, picture Profile (with scope=profile email)
email_verified Whether the issuer verified that address

Two warnings that cause real bugs:

  • The user's key is sub, never email. An email address can be changed, can be reassigned and can arrive unverified. Linking accounts by email address is a route to account takeover if email_verified is false.
  • The id_token is for the client, not for the API. It is not sent in Authorization: Bearer to the resource server. Our API validates the access token; the id_token is consumed by CataBox to know who it has connected.

The /userinfo endpoint completes the picture: it is called with the access token and returns up-to-date profile claims. It is used when the id_token was issued a while ago or when you prefer not to fatten it.

GET /userinfo HTTP/1.1
Host: auth.aromastore.example
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

And the discovery document, which saves you configuring URLs by hand:

GET /.well-known/openid-configuration HTTP/1.1
Host: auth.aromastore.example
{
  "issuer": "https://auth.aromastore.example",
  "authorization_endpoint": "https://auth.aromastore.example/authorize",
  "token_endpoint": "https://auth.aromastore.example/token",
  "userinfo_endpoint": "https://auth.aromastore.example/userinfo",
  "jwks_uri": "https://auth.aromastore.example/.well-known/jwks.json",
  "revocation_endpoint": "https://auth.aromastore.example/revoke",
  "introspection_endpoint": "https://auth.aromastore.example/introspect",
  "scopes_supported": ["openid", "profile", "email", "coffees.read", "orders.read",
                       "orders.write", "reviews.moderate", "shipments.write"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256"]
}

That is where jwks_uri comes from, which is the only thing our API needs.

  1. Validating the token in the API: JWKS, kid and the claims

Here our code finally comes in. We saw the mechanics of JWTs back in 03-06; what is new is two things: the signature is asymmetric (the AS signs with its private key, we verify with its public key) and the public key is discovered dynamically through JWKS.

What JWKS is

JWKS (JSON Web Key Set) is a public document containing the issuer's verification keys:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-08",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4...",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "aroma-2026-05",
      "alg": "RS256",
      "n": "sXchDaQebHnPiGvyDOAT4saGEUetSyo9MKLOoWFsueri23V0dpBB...",
      "e": "AQAB"
    }
  ]
}

Having two keys at once is not a mistake: it is rotation. The AS starts signing with aroma-2026-08 while the tokens signed with aroma-2026-05 remain current until they expire. Each JWT's header says which one to use:

{ "alg": "RS256", "typ": "JWT", "kid": "aroma-2026-08" }

This natively solves the secret rotation problem we raised in 04-02: nothing has to be redeployed, the API discovers the new key by itself.

The middleware

npm install jose

jose is the de facto standard library for JOSE/JWT in Node: it implements the JWKS cache, rotation and the standard's checks.

// src/config/oauth.js  (NEW file)
import { createRemoteJWKSet } from 'jose';
import { environment } from './environment.js';

/**
 * The authorisation server's remote set of public keys.
 *
 * createRemoteJWKSet returns a function that 'jose' uses to resolve the key
 * from the 'kid' in the token's header. Internally it:
 *  - downloads the JWKS the first time and keeps it in memory;
 *  - re-downloads it if an unknown 'kid' arrives (automatic rotation);
 *  - throttles those reloads so that a token with a made-up 'kid' does not become
 *    a denial-of-service attack against the AS.
 */
export const jwks = createRemoteJWKSet(new URL(environment.OAUTH_JWKS_URI), {
  cooldownDuration: 30_000,     // does not re-fetch more than once every 30 s
  cacheMaxAge: 600_000,         // refreshes the key set every 10 min
  timeoutDuration: 5_000,       // gives up if the AS does not answer within 5 s
});

export const OAUTH = {
  issuer: environment.OAUTH_ISSUER,          // https://auth.aromastore.example
  audience: environment.OAUTH_AUDIENCE,      // https://api.aromastore.example
  algorithms: ['RS256'],                     // a closed allowlist
  clockTolerance: '30s',
};
// src/middleware/oauth-authentication.js  (NEW file)
import { jwtVerify, errors as joseErrors } from 'jose';
import { jwks, OAUTH } from '../config/oauth.js';
import { errors } from '../errors/api-error.js';

/**
 * Authenticates the request with an access token issued by the external
 * authorisation server. Leaves the subject and its scopes in req.user.
 */
export async function authenticateOAuth(req, res, next) {
  const header = req.get('Authorization') ?? '';

  // 1. The scheme must be exactly 'Bearer'.
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) {
    res.set('WWW-Authenticate', `Bearer realm="api.aromastore.example"`);
    return next(errors.notAuthenticated('not_authenticated', 'The access token is missing.'));
  }

  try {
    // 2. Full verification: signature + registered claims.
    const { payload } = await jwtVerify(token, jwks, {
      issuer: OAUTH.issuer,               // iss must be OUR authorisation server
      audience: OAUTH.audience,           // aud must be OUR API
      algorithms: OAUTH.algorithms,       // RS256 only: blocks alg:none and HS/RS confusion
      clockTolerance: OAUTH.clockTolerance,   // slack for clocks that drift
    });
    // jwtVerify has already checked exp (not expired) and nbf (not used too early).

    // 3. The scopes arrive as a space-separated string.
    const scopes = new Set((payload.scope ?? '').split(' ').filter(Boolean));

    // 4. A normalised subject: the API uses it exactly like the one from 03-06.
    req.user = {
      id: payload.sub,                      // cus_842, or 'swiftship' in client credentials
      role: payload.role ?? deriveRole(payload),
      scopes,
      oauthClient: payload.client_id ?? payload.azp ?? null,   // who is acting: catabox, spa...
      tokenSource: 'oauth',
    };
    return next();
  } catch (error) {
    return next(translateJoseError(error, res));
  }
}

/**
 * Translates 'jose' errors into the Aroma Store catalogue and sets
 * WWW-Authenticate according to RFC 6750 (Bearer Token Usage).
 */
function translateJoseError(error, res) {
  if (error instanceof joseErrors.JWTExpired) {
    res.set('WWW-Authenticate',
      `Bearer error="invalid_token", error_description="The access token expired"`);
    return errors.tokenExpired('token_expired', 'The access token has expired.');
  }
  if (error instanceof joseErrors.JWKSNoMatchingKey) {
    // An unknown 'kid': a token from another issuer or a retired key.
    res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
    return errors.notAuthenticated('not_authenticated', 'The access token is not valid.');
  }
  if (error instanceof joseErrors.JWKSTimeout) {
    // The authorisation server is not responding: this is NOT the client's fault.
    return errors.serviceUnavailable(
      'service_unavailable',
      'The token could not be verified at this time.'
    );
  }
  res.set('WWW-Authenticate', `Bearer error="invalid_token"`);
  return errors.notAuthenticated('not_authenticated', 'The access token is not valid.');
}

/**
 * Requires one or more scopes. It is composed AFTER authenticateOAuth.
 */
export function requireScope(...required) {
  return (req, res, next) => {
    const hasAll = required.every((s) => req.user?.scopes?.has(s));
    if (!hasAll) {
      res.set('WWW-Authenticate',
        `Bearer error="insufficient_scope", scope="${required.join(' ')}"`);
      return next(
        errors.permissionDenied(
          'insufficient_permissions',
          `The token does not include the required scope: ${required.join(', ')}.`
        )
      );
    }
    return next();
  };
}

The claims that have to be verified, and what happens if you skip one:

Claim What it checks If you do not validate it
signature That it was issued by whoever it says Anyone can manufacture tokens
iss Who issued it You accept tokens from any other issuer
aud Who it is for You accept a token issued for another API: the audience confusion attack
exp Not expired Tokens never expire
nbf Not used too early You accept pre-issued tokens
alg (allowlist) The expected algorithm alg:none and RS/HS confusion
scope The specific permission Any token is good for everything

aud is the most forgotten and one of the most serious. If a user has a token for another application from the same authorisation server and your API does not check aud, that token opens your API.

About clock skew: exp and nbf are absolute instants. If your server's clock is thirty seconds fast, you will reject freshly issued tokens with an incomprehensible token_expired. Hence clockTolerance, and hence servers running NTP. A reasonable tolerance is 30–60 seconds; more than that starts to become a risk.

About the key cache: without it you would make an HTTP request to the AS for every incoming request, which would throw away the advantage of local validation and create a hard dependency. With it, the AS can be down for ten minutes without your API failing to authenticate anybody.

  1. The authenticateOAuth middleware and its coexistence with authenticate

You do not have to choose all at once. During the migration, the two mechanisms coexist: our own tokens from 03-06 (HS256, issued by our API) and the OAuth ones (RS256, issued by the AS). A selector middleware decides based on the token's header:

// src/middleware/authentication.js  (MODIFIED: the selector is added)
import { decodeProtectedHeader } from 'jose';
import { authenticateOwn } from './own-authentication.js';   // the one from 03-06, renamed
import { authenticateOAuth } from './oauth-authentication.js';

/**
 * The single entry point for authentication.
 * It picks the verifier according to the algorithm declared in the token's header.
 * Note: the header is NOT trustworthy on its own; it is only used to ROUTE,
 * and each verifier then imposes its own allowlist of algorithms.
 */
export function authenticate(req, res, next) {
  const token = (req.get('Authorization') ?? '').split(' ')[1];
  if (!token) return authenticateOwn(req, res, next);   // it will answer 401 with WWW-Authenticate

  let header;
  try {
    header = decodeProtectedHeader(token);   // only decodes, does NOT verify
  } catch {
    return authenticateOwn(req, res, next);
  }

  return header.alg === 'RS256'
    ? authenticateOAuth(req, res, next)
    : authenticateOwn(req, res, next);
}

The comment in the code points at what matters: the token's header is untrusted input. It is used only to decide which verifier takes the request; that verifier then imposes algorithms: ['RS256'] or ['HS256'] as appropriate, so an attacker gains nothing by lying in alg.

Position in the chain. It does not change from 03-06: authentication is still the first thing inside each route, not in src/app.js. The route chain's order becomes:

authenticate → requireRole / requireScope → requireIdempotencyKey → validate(schema, source) → asyncHandler(controller)

And src/app.js is untouched in this lesson: OAuth adds no global middleware.

  1. Scopes and roles combined

There is a frequent confusion here worth clearing up precisely, because they are two mechanisms answering different questions:

Role (customer, employee, administrator, partner) Scope (orders.read)
Answers Who the subject is What they let this application do
Decided by Aroma Store, in its database The user, on the consent screen
Changes Rarely On every authorisation
Example Marta is a customer Marta allowed CataBox orders.read

Effective authorisation is the intersection of the two. A token with reviews.moderate whose subject is a customer cannot moderate: the scope says what the application asked for, but the role says what the person can do. And the other way round: an administrator using CataBox, which only asked for orders.read, cannot moderate reviews from CataBox even though her role would allow it in the back office.

// src/routes/reviews.js  (MODIFIED)
router.post(
  '/:id/approval',
  authenticate,                                 // who are you? (own token or OAuth)
  requireRole('employee', 'administrator'),     // can you as a person moderate?
  requireScope('reviews.moderate'),             // does this application have permission to?
  validate(approvalSchema, 'body'),
  asyncHandler(controllers.reviews.approve)
);

And a compatibility rule for the coexistence: an own token from 03-06 carries no scope. So that requireScope does not break the existing routes, authenticateOwn fills scopes with the complete set corresponding to the role; that is, a first-party token behaves as if the user had consented to everything. It is coherent, because in that flow the user is using our own application directly.

  1. Client registration, secrets and redirect_uri

Before CataBox can ask for anything, it registers at the authorisation server and obtains:

Item Example Public
client_id catabox Yes
client_secret cbx_sk_9f3a... (fictional) No, and only if it is confidential
redirect_uri https://catabox.example/callback Yes
Allowed scopes coffees.read orders.read Yes
Client type Confidential / public

Confidential versus public:

Confidential Public
Can keep a secret Yes (a server under its control) No
Examples CataBox's backend, SwiftShip The shop's SPA, Aroma Mobile
Authentication at /token client_secret PKCE only
Client Credentials Allowed Forbidden

A very repeated mistake: embedding the client_secret in a SPA or a mobile app. Anything downloaded to the user's device is public, however obfuscated or compiled it is. That is the exact reason PKCE exists.

The redirect_uri is compared byte for byte. Not by prefix, not by wildcard, not ignoring the fragment. An AS that accepts https://catabox.example/* lets an attacker who controls any path on that domain (a profile page with uploaded content, say) receive the authorisation codes. The rules: HTTPS mandatory, no wildcards, no dynamic parameters — whatever you need to remember goes in state — and on mobile, custom schemes or verified App Links rather than a scheme any app can register.

  1. Revocation and logout

Token revocation (RFC 7009):

POST /revoke HTTP/1.1
Host: auth.aromastore.example
Authorization: Basic <client credentials>
Content-Type: application/x-www-form-urlencoded

token=def50200a1b2c3...&token_type_hint=refresh_token

It answers 200 even if the token did not exist, deliberately: that way the endpoint cannot be used to find out whether a token is valid.

What to revoke and what effect it has:

Action Immediate effect Deferred effect
Revoke the refresh token No new tokens can be requested The access token lives until its exp (≤ 15 min)
Revoke the access token (opaque) It stops working right away
Revoke the access token (JWT) None, unless there is a revocation list It expires on its own
The user withdraws consent The whole family is revoked Ditto
A password change Every session is revoked Ditto

Logout. There are three levels here that are worth not confusing, because the user thinks "log out" is a single thing:

  1. Local: the application deletes its tokens. The user still has an open session at the AS.
  2. RP-Initiated Logout (OIDC): the application redirects to end_session_endpoint and the AS closes its session too.
  3. Back-Channel Logout: the AS notifies every connected application behind the scenes so that they close the session. This is what a real single sign-on scenario needs.

In the Aroma Store SPA, "log out" must be at least level 2; if you only do level 1, pressing "sign in" again puts the user straight back in without asking for anything, and on a shared computer that is a security problem.

  1. Why you should not implement your own authorisation server

You will have guessed it while reading the lesson: implementing a correct OAuth 2.0 authorisation server is a project in itself, and doing it badly does not produce a visible failure, it produces a silent vulnerability.

What has to be built and maintained correctly:

  • Issuing and validating single-use codes, short-lived and bound to the client.
  • PKCE with S256, exact redirect_uri comparison, state and nonce validation.
  • Asymmetric signing, JWKS publication and key rotation without cutting off the service.
  • Refresh token rotation with reuse detection and cascading revocation.
  • Consent screens, a record of consents and their withdrawal.
  • Password management, second factor, account recovery, lockout after failed attempts.
  • Discovery, introspection, revocation and userinfo endpoints.
  • Keeping up with updates to the standard and with security advisories.

On top of that, your authorisation server is the system's most critical asset: whoever compromises it compromises everything.

Option When it makes sense Considerations
Auth0 / Okta SaaS, you want zero maintenance Cost per active user; an external dependency
AWS Cognito / Azure AD B2C You are already in that cloud Good integration; limited customisation
Keycloak Self-hosted, full control, no licence cost You operate, update and secure the service
Ory Hydra You want the AS but want to manage login yourself Lightweight, but more pieces to integrate
Implementing it yourself Practically never Only with a dedicated security team

Aroma Store's decision: a managed provider at auth.aromastore.example. Our API remains solely a resource server, which is exactly the role we have implemented.

And a useful clarification: this does not invalidate the work from 03-06. Own tokens remain perfectly reasonable for the first-party case — your SPA talking to your API, with no third parties. OAuth comes in when a third party appears, when you need federated login or when several APIs share an identity.

  1. OAuth errors and their translation into the Aroma catalogue

OAuth defines its own error codes, in this shape:

{
  "error": "invalid_grant",
  "error_description": "The authorization code has expired",
  "error_uri": "https://auth.aromastore.example/docs/errors#invalid_grant"
}

That format is emitted by the authorisation server, not by our API. The errors a CataBox client sees break down like this:

OAuth error Emitted by Meaning In the Aroma catalogue
invalid_request AS A mandatory parameter is missing
invalid_client AS Wrong client_id/client_secret
invalid_grant AS Code expired, already used, or refresh revoked
unauthorized_client AS That client may not use that flow
unsupported_grant_type AS An unknown grant_type
invalid_scope AS A non-existent scope, or one not allowed to the client
access_denied AS The user refused consent
invalid_token Our API A token that is unverifiable, malformed or from another issuer not_authenticated (401)
invalid_token (expired) Our API exp in the past token_expired (401)
insufficient_scope Our API Scopes missing insufficient_permissions (403)

Our API keeps its own format in the body and uses the WWW-Authenticate header to speak OAuth's language, which is what RFC 6750 expects:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders.write"
Content-Type: application/json

{
  "error": {
    "code": "insufficient_permissions",
    "message": "The token does not include the required scope: orders.write.",
    "details": []
  }
}

That way both things hold at once: the catalogue's consistency towards our consumers (04-01) and interoperability with generic OAuth libraries, which read WWW-Authenticate to decide whether they should refresh the token or ask for more permissions. There is no need to extend the error catalogue: not_authenticated, token_expired, insufficient_permissions and service_unavailable cover every case the resource server emits.

Common Mistakes and Tips

Using the access token as proof of identity. The access token is for the API; identity comes from OIDC's id_token. Confusing them opens the door to token substitution attacks.

Not validating aud. A token issued for another application from the same issuer would open your API. It is the most forgotten check.

Accepting any algorithm. Without an allowlist, alg:none and RS/HS confusion are exploitable. Pin ['RS256'].

Storing tokens in localStorage. Any XSS steals them. An HttpOnly cookie with a BFF, or the system keychain on mobile.

Embedding a client_secret in a SPA or mobile app. It is public by definition. Public client + PKCE.

Accepting a redirect_uri with wildcards. A single controllable path on your domain turns into code theft.

Skipping state because "we already use PKCE". They protect against different attacks. Both, always.

Asking for every scope "just in case". It lowers conversion — the user sees an alarming screen — and increases the damage of a breach. Ask for the minimum and extend when you need to.

Tip: start from the discovery document. /.well-known/openid-configuration gives you every URL. Configuring token_endpoint by hand is a source of silly mistakes.

Tip: test expiry for real. Configure a test AS with 30-second tokens and check that your client refreshes on its own and that your API answers token_expired with the right WWW-Authenticate.

Tip: log the client_id. Knowing that a wave of 429s comes from CataBox and not from your SPA changes the diagnosis completely. We will see this in 04-07.

Exercises

Exercise 1: choosing the flow

For each Aroma Store consumer, state the correct flow, the client type and the minimum scopes. Justify each choice.

  1. The shop's SPA (https://aromastore.example), which allows buying and reviewing.
  2. The Aroma Mobile app, with the same functions.
  3. SwiftShip's backend, which marks orders as shipped.
  4. CataBox, which imports the user's purchase history.
  5. An internal nightly script that exports sales statistics.

Exercise 2: finding the flaws in a validation

This middleware has been proposed for validating OAuth tokens. Find at least four security flaws and fix them.

import jwt from 'jsonwebtoken';

export async function authenticateOAuth(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
  const jwksResponse = await fetch('https://auth.aromastore.example/.well-known/jwks.json');
  const { keys } = await jwksResponse.json();
  const key = keys.find((k) => k.kid === header.kid) ?? keys[0];
  const payload = jwt.verify(token, toPem(key));
  req.user = { id: payload.email, scopes: payload.scope };
  next();
}

Exercise 3: designing the scopes for a new feature

Aroma Store is going to let third-party applications manage a customer's monthly coffee subscription: view it, pause it, resume it and change the variety. Design the necessary scopes, the consent text for each one, and decide which combination of role and scope each endpoint requires.

Solutions

Solution 1

Consumer Flow Client type Minimum scopes
1. The shop's SPA Authorization Code + PKCE (ideally with a BFF) Public openid coffees.read orders.read orders.write reviews.write
2. Aroma Mobile Authorization Code + PKCE Public The same
3. SwiftShip Client Credentials Confidential shipments.write
4. CataBox Authorization Code + PKCE Confidential (it has a backend) openid orders.read
5. Internal script Client Credentials Confidential sales.read (a new scope)

Justifications:

  • 1 and 2 are public even though they are ours: the code is downloaded to the device and cannot keep a secret. And they use Authorization Code + PKCE, not the password flow, so they can go through 2FA and never handle passwords.
  • 3 and 5 have no user present, so Client Credentials. The sub is the machine itself and there is no consent.
  • 4 is confidential because the exchange happens on its server, but it uses PKCE all the same: the current recommendation is to apply it always. It asks only for orders.read; if it asked for orders.write the registration should be refused, because it does not need it for its use case.
  • Case 5 requires extending the scope catalogue with sales.read, and it is worth noting explicitly: scopes, like error codes, are part of the contract and are documented in openapi.yaml.

Solution 2

Flaws:

  1. It validates neither iss nor aud: it accepts tokens from any issuer and tokens issued for any other API.
  2. It does not pin algorithms: vulnerable to alg:none and to RS/HS confusion.
  3. ?? keys[0]: if the kid matches no key, it uses the first one "and hopes for the best". An unknown kid must be a rejection.
  4. It downloads the JWKS on every request: added latency, a hard dependency on the AS and a denial-of-service vector (all it takes is sending tokens with made-up kids).
  5. id: payload.email: the stable identifier is sub. An email address can change or arrive unverified.
  6. scopes: payload.scope leaves a string where a set is expected; .has() would fail or, worse, .includes() would produce false positives (orders.read "includes" orders.rea).
  7. No try/catch: if the header is missing or the token is malformed, the split or the JSON.parse throws a TypeError and it ends up as a 500 instead of a 401 — and with no WWW-Authenticate.

The fix is the middleware from section 12: createRemoteJWKSet with a cache for 3 and 4, jwtVerify with issuer, audience and algorithms for 1 and 2, payload.sub for 5, a Set for 6 and the try/catch with error translation for 7.

Solution 3

Scopes:

Scope Consent text Justification
subscriptions.read "See your coffee subscription and its next delivery" Read-only, low risk
subscriptions.write "Pause, resume and change your coffee subscription" It modifies state; kept separate from reading

Deliberately, a scope per action is not created (subscriptions.pause, subscriptions.resume…): it would produce an unreadable consent screen and none of those actions has a risk profile different from the others. Nor is orders.write reused, because an application that manages subscriptions must not be able to create one-off orders: mixing the two would extend the permission beyond what is needed.

Endpoints:

Endpoint Role Scope Note
GET /v1/customers/{id}/subscription customer (own), employee, administrator subscriptions.read Ownership check mandatory
POST /v1/customers/{id}/subscription/pause customer (own), administrator subscriptions.write Idempotent: pausing twice leaves it the same
POST /v1/customers/{id}/subscription/resumption customer (own), administrator subscriptions.write Ditto
PATCH /v1/customers/{id}/subscription customer (own), administrator subscriptions.write Changes the variety; an absolute patch
DELETE /v1/customers/{id}/subscription customer (own) subscriptions.write Cancellation; an employee does not cancel on their own initiative
router.post(
  '/:id/subscription/pause',
  authenticate,
  requireRole('customer', 'administrator'),
  requireScope('subscriptions.write'),
  asyncHandler(controllers.subscriptions.pause)   // inside: the ownership check
);

Two final observations. requireRole('customer', ...) is not enough to stop a customer pausing somebody else's subscription: that is a BOLA and it is checked in the service, as we saw in 04-02. And the actions are modelled as noun subresources (/pause, /resumption) rather than verbs, consistently with 02-02 and with the antipatterns from 04-01.

Conclusion

OAuth 2.0 solves a very concrete problem that the authentication from 03-06 could not: letting a third-party application access a user's data without knowing their password, with bounded permissions, expiry and revocation. You have seen the four roles and, above all, that our API is only the resource server: it receives a token, verifies it and decides; nothing more. You know the difference between an access token and a refresh token, the scopes designed for Aroma Store and why reading is always separated from writing; the choice between an opaque token with introspection and a JWT with local validation, with its explicit trade-off on revocation; the Authorization Code + PKCE flow with the detail of exactly which attack the code_verifier prevents, why the implicit and password flows are retired, Client Credentials for SwiftShip and refresh token rotation with reuse detection. You have seen that OpenID Connect is the layer that answers "who is it" with the id_token, sub as the stable key and /userinfo for the profile. And in the project you now have src/config/oauth.js and src/middleware/oauth-authentication.js, with verification by JWKS and kid — which incidentally solves key rotation — full validation of iss, aud, exp, nbf and algorithm, a key cache, clock tolerance, the new requireScope and a selector that lets our own tokens coexist with the OAuth ones.

You now know who is calling and what they can do. What is missing is how much they can call. In 04-04, Rate Limiting and Throttling, we will impose limits: we will see why every public API needs them — abuse, catalogue scraping, badly programmed client loops, cost and fairness — the difference between limiting, throttling and quotas, and the four classic algorithms with a comparison, including a token bucket implemented and commented. We will decide what is used as the key (IP, with the NAT problem and X-Forwarded-For; the authenticated customer; the OAuth client_id) and with which tiers for anonymous users, customers, SwiftShip and the back office; we will build src/middleware/rate-limit.js with express-rate-limit and its position in src/app.js, with a Redis store for multiple instances; we will return the 429 with Retry-After and the Aroma-RateLimit-* headers; and we will see what a well-behaved client should do with its exponential backoff and its jitter.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved