You got OAuth working. Users click "Login with Google," a token comes back, your API accepts it. Shipped.
And then someone steals that token from the URL fragment. Or intercepts the authorization code on a mobile device. Or crafts a redirect URI that funnels credentials to their domain. These aren't theoretical attacks. They're documented in RFC 9700 with section numbers and mitigation steps, and they work against implementations that looked fine on the happy path.
If you've read my intro to OAuth and OpenID Connect, you know the basic flow. This post picks up where that one left off. Here's what you need to actually harden it.
The flows you need to stop using
Two OAuth grants are officially dead. Not deprecated-but-still-kinda-fine. Dead.
Implicit flow (response_type=token) puts access tokens directly in the URL fragment. That means they show up in browser history, leak through Referer headers to any third-party resource on your callback page, and get exposed by open redirectors. You can't sender-constrain them. You can't rotate them. RFC 9700 Section 2.1.2 says SHOULD NOT. OAuth 2.1 removes it entirely.
Resource Owner Password Credentials (ROPC) hands the user's actual password to your client application. This breaks the entire point of delegated authorization. It's also incompatible with MFA, WebAuthn, passkeys, or any modern authentication mechanism. RFC 9700 Section 2.4 says MUST NOT. Gone in 2.1.
So what do you use? Authorization Code flow. For everything. Public clients, confidential clients, SPAs, mobile apps. One flow to rule them all, protected by PKCE.
🔑 PKCE is non-negotiable
PKCE (Proof Key for Code Exchange, RFC 7636) solves three problems at once: code interception, code injection, and CSRF.
Here's the attack without it. Your app redirects the user to the authorization server. The AS issues a code and redirects back. But on mobile, multiple apps can register the same custom URI scheme. A malicious app intercepts the redirect, grabs the code, and exchanges it for tokens. Game over.
With PKCE, your app generates a random code_verifier before starting the flow, computes a code_challenge from it (SHA-256 hash, base64url-encoded), and sends only the challenge to the AS. When exchanging the code for tokens, you prove possession of the original verifier. The attacker has the code but not the verifier. Useless.
import crypto from 'node:crypto';
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { code_verifier: verifier, code_challenge: challenge, method: 'S256' };
}
Always use S256. The plain method sends the verifier as the challenge itself, which means anyone who can read the authorization request already has it. Defeats the purpose.
RFC 9700 Section 2.1.1 makes PKCE mandatory for public clients. But honestly, use it for confidential clients too. OAuth 2.1 will require it for all client types. There's no downside.
🎯 Redirect URI: exact match or get wrecked
Your authorization server must validate redirect URIs with exact string matching. Not pattern matching. Not wildcard subdomains. Exact.
Why? Because https://*.myapp.com/callback also matches https://evil.myapp.com/callback. And subdomain takeovers are common. An attacker claims an abandoned subdomain, registers it as a redirect URI, and now authorization codes flow directly to them.
It gets worse with naive pattern matching. A poorly implemented check for https://myapp.com might also accept https://attacker.com/.myapp.com. Combined with open redirectors, this lets attackers steal tokens without needing a subdomain takeover at all.
The only exception RFC 9700 allows: localhost with variable ports for native desktop apps during development (RFC 8252 Section 7.3). Everything else? Exact strings.
Where to store tokens in the browser
This is probably the most debated topic in OAuth security. Every option has problems.
localStorage: Accessible to any JavaScript on your page. One XSS vulnerability and every token is gone. Not great.
Memory only: Safe from XSS-based theft, but tokens vanish on page refresh. Terrible UX.
httpOnly secure cookies: JavaScript can't read them, so XSS can't steal them. But now you need CSRF protection on every request. And you're back to dealing with cookie semantics, SameSite attributes, and cross-origin headaches.
The BFF pattern (Backend For Frontend): This is what RFC 9700 recommends for browser-based apps. Your frontend never touches tokens at all. A server-side component handles the OAuth flow, stores tokens in a server session, and issues a plain httpOnly session cookie to the browser. The browser sends the cookie, the BFF attaches the access token before forwarding to your API.
Tokens never exist in JavaScript. XSS can't steal what isn't there. It adds a component to your architecture, but it's the only approach where a single XSS doesn't mean full token compromise.
🧠Refresh token rotation and reuse detection
Access tokens should live for 5-15 minutes. Short enough that a stolen one has limited blast radius. But you need refresh tokens for session continuity.
For public clients, RFC 9700 Section 4.14.2 requires either sender-constraining (DPoP or mTLS) or refresh token rotation. Rotation means every time a client uses a refresh token, the AS issues a new one and invalidates the old one. Each refresh token is single-use.
The important part: reuse detection. If someone presents an already-used refresh token, that's a compromise signal. The AS should immediately revoke the entire token family. Not just that one token. Everything associated with that session.
So if an attacker steals a refresh token and races the legitimate client, one of them will present the invalidated token. The AS catches it and nukes both sessions. Aggressive, but correct.
âš¡ Validating tokens on your resource server
If you're accepting JWTs as access tokens, validation isn't optional. Every request needs these checks:
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://as.example/.well-known/jwks.json',
});
async function validateAccessToken(token) {
const { header } = jwt.decode(token, { complete: true });
const key = await client.getSigningKey(header.kid);
return jwt.verify(token, key.getPublicKey(), {
issuer: 'https://as.example',
audience: 'https://api.example.com',
algorithms: ['RS256'],
});
}
Check the signature against the AS's published JWKS. Verify iss matches your expected authorization server. Confirm aud includes your resource server's identifier. Reject if exp is in the past. Fail on any one of these and you reject the token. No partial credit.
And one thing I see constantly: don't use ID Tokens as access tokens. An ID Token's audience is the client application. It's an authentication assertion, not an authorization credential. Sending it as a Bearer token to your API is wrong, even if it "works" because your API doesn't check the audience claim. If you need a refresher on how JWTs work and why claims matter, I wrote about that in my JWT deep dive.
The state parameter still matters
Even with PKCE handling CSRF protection, you should still use state. It's cheap insurance.
import crypto from 'node:crypto';
// Before redirecting to AS
const state = crypto.randomBytes(16).toString('base64url');
session.oauthState = state;
// On callback
if (query.state !== session.oauthState) throw new Error('CSRF detected');
delete session.oauthState; // one-time use
Bind it to the user's session. Make it one-time-use. And if your AS supports the iss response parameter (RFC 9207), validate that too. It prevents mix-up attacks where a malicious authorization server tricks your client into sending codes to the wrong token endpoint.
📌 What to actually do
Stop reading specs for a second. Here's the concrete checklist:
- Use Authorization Code + PKCE (S256) for every client type. No exceptions
- Register redirect URIs as exact strings. Kill any wildcard patterns
- Store tokens server-side (BFF pattern) for browser apps. localStorage is a liability
- Set access token lifetime to 5-15 minutes. Use refresh token rotation with reuse detection
- Validate JWT access tokens fully: signature, issuer, audience, expiration
- Never send ID Tokens to your API as bearer credentials
- Include
statein every authorization request. One-time, session-bound, unpredictable
RFC 9700 is 80+ pages, but those seven points cover 90% of what will actually get you owned in production. The spec exists because people shipped without them and got burned.
If you haven't read the basics yet, start with my post on how OAuth and OpenID Connect work together.
More from me
I write about backend systems, auth, and developer tooling at arnavsharma.dev.
Top comments (0)