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
- The problem: delegating access without sharing credentials
- The four roles of OAuth 2.0
- Tokens: access, refresh and scopes
- Opaque tokens versus JWTs: introspection or local validation
- Authorization Code + PKCE
- Why the implicit and password flows are discouraged
- Client Credentials: SwiftShip, machine to machine
- Refresh Token and Device Code
state, CSRF and the authorisation request's parameters- OpenID Connect: identity on top of OAuth
- Validating the token in the API: JWKS,
kidand the claims - The
authenticateOAuthmiddleware and its coexistence withauthenticate - Scopes and roles combined
- Client registration, secrets and
redirect_uri - Revocation and logout
- Why you should not implement your own authorisation server
- OAuth errors and their translation into the Aroma catalogue
- 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).
- 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/v1 — our 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:
- Receive an
Authorization: Bearer <token>. - Verify that the token is genuine, current and addressed to it.
- Extract who the subject is and which scopes they have.
- 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.
- 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:
resource.actiongranularity, not one scope per endpoint. With 24 URIs, one scope per endpoint produces an unreadable consent screen.- Always separate read from write. CataBox asks for
orders.readand neverorders.write. That separation is 80% of the value. - A scope bounds, it does not grant. A token having
orders.readdoes 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. - Write the text the user will see alongside the scope. If you cannot explain it in one comprehensible line, the scope is badly designed.
- 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.
- 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.exampleStep 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=xY9fK2mQ7pL1Step 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.
- 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.
- 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.writeThree characteristics distinguish it:
- There is no
redirect_uri, nocodeand 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
subis the client itself (swiftship), not a person. In our API that corresponds to thepartnerrole, 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.
- 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=cataboxThe 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.
state, CSRF and the authorisation request's parameters
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 useWith 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.
- 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:
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, neveremail. An email address can be changed, can be reassigned and can arrive unverified. Linking accounts by email address is a route to account takeover ifemail_verifiedisfalse. - The
id_tokenis for the client, not for the API. It is not sent inAuthorization: Bearerto the resource server. Our API validates the access token; theid_tokenis 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.
And the discovery document, which saves you configuring URLs by hand:
{
"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.
- Validating the token in the API: JWKS,
kid and the claims
kid and the claimsHere 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:
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
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.
- The
authenticateOAuth middleware and its coexistence with authenticate
authenticateOAuth middleware and its coexistence with authenticateYou 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.
- 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.
- Client registration, secrets and
redirect_uri
redirect_uriBefore 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.
- 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_tokenIt 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:
- Local: the application deletes its tokens. The user still has an open session at the AS.
- RP-Initiated Logout (OIDC): the application redirects to
end_session_endpointand the AS closes its session too. - 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.
- 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, exactredirect_uricomparison,stateandnoncevalidation. - 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
userinfoendpoints. - 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.
- 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.
- The shop's SPA (
https://aromastore.example), which allows buying and reviewing. - The Aroma Mobile app, with the same functions.
- SwiftShip's backend, which marks orders as shipped.
- CataBox, which imports the user's purchase history.
- 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
subis 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 fororders.writethe 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 inopenapi.yaml.
Solution 2
Flaws:
- It validates neither
issnoraud: it accepts tokens from any issuer and tokens issued for any other API. - It does not pin
algorithms: vulnerable toalg:noneand to RS/HS confusion. ?? keys[0]: if thekidmatches no key, it uses the first one "and hopes for the best". An unknownkidmust be a rejection.- 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). id: payload.email: the stable identifier issub. An email address can change or arrive unverified.scopes: payload.scopeleaves a string where a set is expected;.has()would fail or, worse,.includes()would produce false positives (orders.read"includes"orders.rea).- No
try/catch: if the header is missing or the token is malformed, thesplitor theJSON.parsethrows aTypeErrorand it ends up as a500instead of a401— and with noWWW-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
- What Is an API?
- History and Evolution of APIs
- HTTP Fundamentals for APIs
- Basic Principles of REST
- The Richardson Maturity Model and HATEOAS
- REST vs. SOAP
- REST Compared with GraphQL, gRPC and Webhooks
Module 2: Designing RESTful APIs
- RESTful API Design Principles
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Representations, Headers and Content Negotiation
- Filtering, Sorting, Pagination and Search
- API Versioning
- API Documentation
Module 3: Building RESTful APIs
- Setting Up the Development Environment
- Building a Basic Server
- Handling Requests and Responses
- Input Data Validation
- Persistence and the Data Access Layer
- Authentication and Authorisation
- Error Handling
- Testing and Validation
Module 4: Best Practices and Security
- API Design Best Practices
- Security in RESTful APIs
- OAuth 2.0 and OpenID Connect in Practice
- Rate Limiting and Throttling
- CORS and Security Policies
- HTTP Caching and Performance
- Observability: Logs, Metrics and Traces
Module 5: Tools and Frameworks
- Postman for API Testing
- Swagger and OpenAPI for Documentation
- Popular Frameworks for RESTful APIs
- Contracts, Mocks and Automated API Testing
- Continuous Integration and Deployment
- API Gateways and Developer Portals
