Introduction
Storing access tokens in localStorage is a vulnerability, not a convenience. If you are doing this, fix it before reading the rest.
One XSS hole and the attacker has your users' tokens. Not "might have" -- has them. localStorage is readable by any JavaScript running on your page, including whatever gets injected through that unescaped user input field nobody audited. And yet half the OAuth tutorials on the internet store tokens there because it is easy to show in a demo.
Here is what to actually do, starting with the correct answer and working backward to the theory.
The Right Flow for Your App
Server-side app (Express, Django, Rails, any backend that can keep a secret): Authorization Code flow. Access token never touches the browser. Client secret stays on your server. Tokens go in the server session.
SPA or mobile app (React, Vue, iOS, Android -- anything where the source is public): Authorization Code with PKCE. No client secret, because you cannot keep one. The code verifier proves you are the same client that started the flow. Store refresh tokens in HTTP-only secure cookies, access tokens in memory.
Service-to-service (no user involved, just your backend calling another backend): Client Credentials. Machine identity, not user identity.
That is the decision tree. Everything below explains how each one works and why the alternatives are worse.
Quick vocabulary, because the spec names are not obvious. Four roles in every flow: Resource Owner (the user), Client (your app), Authorization Server (issues tokens -- Google, GitHub, your Keycloak instance), Resource Server (the API that validates tokens). The authorization server and resource server can be the same system or completely separate. A grant type is the method for obtaining tokens. A scope limits permissions. The state parameter prevents CSRF. These show up in every code sample below.
Authorization Code Flow
The flow works like this. Your app redirects the user to the authorization server -- a URL with your client ID, requested scopes, a redirect URI, and a random state parameter. User logs in, sees a consent screen, approves. The authorization server redirects back to your app with an authorization code and the state parameter you sent. Your server then exchanges that code for tokens by making a direct server-to-server request to the token endpoint. This exchange requires your client secret, which proves the request actually came from your server and not from someone who intercepted the code in transit. Back comes an access token, usually a refresh token, and expiration metadata.
const express = require("express");
const crypto = require("crypto");
const app = express();
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET;
const REDIRECT_URI = "https://myapp.com/auth/callback";
const AUTH_URL = "https://provider.com/oauth/authorize";
const TOKEN_URL = "https://provider.com/oauth/token";
// Step 1: Redirect user to authorization server
app.get("/auth/login", (req, res) => {
const state = crypto.randomBytes(32).toString("hex");
req.session.oauthState = state;
const params = newURLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "openid profile email",
state: state,
});
res.redirect(`${AUTH_URL}?${params}`);
});
// Step 2: Handle the callback and exchange the code
app.get("/auth/callback", async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRFif (state !== req.session.oauthState) {
return res.status(403).send("Invalid state parameter");
}
delete req.session.oauthState;
// Exchange authorization code for tokensconst response = awaitfetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: newURLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const tokens = await response.json();
// tokens = { access_token, refresh_token, expires_in, token_type }// Store tokens securely in session (never in the browser)
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect("/dashboard");
});The state parameter is the CSRF defense. Crypto-random, stored server-side, verified on callback. Does not match? Reject. No exceptions. No "but it works without it in development."
Token exchange is server-to-server. Client secret never leaves your backend. Tokens go into the session. Not localStorage. Not a cookie the browser can read.
But SPAs and mobile apps cannot keep a client secret. The JavaScript bundle is public. The APK can be decompiled.
PKCE: Protecting Public Clients
PKCE ("pixy") adds a per-request proof. Client generates a random code verifier, hashes it with SHA-256 to produce a code challenge. Challenge goes into the authorization URL. On token exchange, the client sends the original verifier. Server hashes it, checks the match. Attacker who intercepted the code does not have the verifier. Exchange fails. No client secret needed.
// Generate a cryptographically random code verifierfunctiongenerateCodeVerifier() {
const array = newUint8Array(32);
crypto.getRandomValues(array);
returnbtoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Create the SHA-256 code challenge from the verifierasync functiongenerateCodeChallenge(verifier) {
const encoder = newTextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
returnbtoa(String.fromCharCode(...newUint8Array(hash)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Step 1: Start the login flowasync functionstartLogin() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = awaitgenerateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
// Store verifier and state for later verification
sessionStorage.setItem("pkce_verifier", codeVerifier);
sessionStorage.setItem("oauth_state", state);
const params = newURLSearchParams({
response_type: "code",
client_id: "your-client-id",
redirect_uri: "https://myapp.com/callback",
scope: "openid profile email",
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
window.location.href = `https://provider.com/authorize?${params}`;
}
// Step 2: Handle callback and exchange code with verifierasync functionhandleCallback() {
const params = newURLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
if (state !== sessionStorage.getItem("oauth_state")) {
throw newError("State mismatch -- possible CSRF attack");
}
const codeVerifier = sessionStorage.getItem("pkce_verifier");
const response = awaitfetch("https://provider.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: newURLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: "https://myapp.com/callback",
client_id: "your-client-id",
code_verifier: codeVerifier, // No client_secret needed!
}),
});
const tokens = await response.json();
// Clean up stored values
sessionStorage.removeItem("pkce_verifier");
sessionStorage.removeItem("oauth_state");
return tokens;
}OAuth 2.1 recommends PKCE for all clients, including server-side ones. No downside to adding it, and it gives you defense-in-depth if your client secret ever leaks.
Always use S256. The spec also allows plain, which sends the verifier unhashed. Protects against nothing.
Access Tokens and Refresh Tokens
Two tokens. Not interchangeable.
Access token: goes on API requests. Short-lived -- minutes to an hour. Scoped. When it expires, it is gone. Refresh token: gets new access tokens without forcing re-login. Lasts weeks or months. Much more attractive target. Treat it like a password.
async functionrefreshAccessToken(refreshToken) {
const response = awaitfetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: newURLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!response.ok) {
throw newError("Token refresh failed");
}
return response.json();
}
// Middleware to ensure valid access token on every requestasync functionensureAuthenticated(req, res, next) {
if (!req.session.accessToken) {
return res.redirect("/auth/login");
}
// Check if the access token is about to expireconst expiresAt = req.session.tokenExpiresAt || 0;
const bufferSeconds = 60; // Refresh 60 seconds before expiryif (Date.now() >= (expiresAt - bufferSeconds) * 1000) {
try {
const tokens = awaitrefreshAccessToken(req.session.refreshToken);
req.session.accessToken = tokens.access_token;
req.session.tokenExpiresAt = Date.now() / 1000 + tokens.expires_in;
// Rotate refresh token if the server issues a new oneif (tokens.refresh_token) {
req.session.refreshToken = tokens.refresh_token;
}
} catch (err) {
// Refresh failed -- force re-login
req.session.destroy();
return res.redirect("/auth/login");
}
}
next();
}Same as the auth code exchange but with the refresh token instead of the authorization code. The 60-second buffer matters -- without it, the token can expire between the "is it still valid?" check and the actual API call. Race condition.
Some providers issue a new refresh token with every refresh and invalidate the old one. If your code ignores the new refresh token, the user gets silently logged out next time. Always check for and save a new refresh token in the response.
For browser-based apps: refresh tokens in HTTP-only secure SameSite cookies. Not localStorage. Not sessionStorage. Not anything JavaScript can read.
JWT Validation and Claims
An unvalidated JWT is just a JSON blob anyone can forge. Three base64url-encoded parts separated by dots: header, payload, signature. The signature is the only part that matters for trust, and you must verify it server-side. Check the issuer. Confirm the audience. Ensure it has not expired. Skip any of these and you are trusting user input.
import jwt
import requests
from functools import lru_cache
from flask import Flask, request, jsonify
app = Flask(__name__)
ISSUER = "https://accounts.google.com"
AUDIENCE = "your-client-id.apps.googleusercontent.com"
JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs"@lru_cache(maxsize=1)
defget_signing_keys():
"""Fetch the provider's public signing keys (JWKS)."""
response = requests.get(JWKS_URI)
jwks = response.json()
return {
key["kid"]: jwt.algorithms.RSAAlgorithm.from_jwk(key)
for key in jwks["keys"]
}
defvalidate_jwt(token):
"""Validate a JWT with full security checks."""# Step 1: Decode the header to find the key ID
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
# Step 2: Look up the matching public key
signing_keys = get_signing_keys()
if kid not in signing_keys:
# Keys might have rotated -- clear cache and retry
get_signing_keys.cache_clear()
signing_keys = get_signing_keys()
if kid not in signing_keys:
raiseValueError("Unknown signing key")
# Step 3: Verify signature, issuer, audience, and expiration
payload = jwt.decode(
token,
key=signing_keys[kid],
algorithms=["RS256"],
issuer=ISSUER,
audience=AUDIENCE,
options={
"require": ["exp", "iss", "aud", "sub"],
},
)
return payload
@app.route("/api/profile")
defget_profile():
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
returnjsonify({"error": "Missing bearer token"}), 401
token = auth_header[7:]
try:
claims = validate_jwt(token)
returnjsonify({
"user_id": claims["sub"],
"email": claims.get("email"),
"name": claims.get("name"),
})
except jwt.ExpiredSignatureError:
returnjsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError as e:
returnjsonify({"error": str(e)}), 401The JWKS endpoint gives us the provider's public signing keys. Cache them. Handle key rotation -- unknown key ID means clear cache and refetch. The require option is the one people skip. Without it, a token missing an exp claim is treated as never-expiring. That is not a theoretical risk. That is a hole.
Pin the algorithm to ["RS256"]. There is a well-known attack where the JWT header gets rewritten to none (no signature required) or switched from RSA to HMAC using the public key as the HMAC secret. Specifying the algorithm explicitly kills both vectors.
Implementing Social Login
Google and GitHub. The two you will wire up most often.
Google OAuth 2.0
Google layers OpenID Connect on top of OAuth 2.0, so you get a standardized ID token alongside the access token.
import os, secrets, requests
from flask import Flask, redirect, request, session, url_for
from urllib.parse import urlencode
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
GOOGLE_CLIENT_ID = os.environ["GOOGLE_CLIENT_ID"]
GOOGLE_CLIENT_SECRET = os.environ["GOOGLE_CLIENT_SECRET"]
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"@app.route("/login/google")
defgoogle_login():
state = secrets.token_urlsafe(32)
session["oauth_state"] = state
params = urlencode({
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": url_for("google_callback", _external=True),
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "offline", # Request a refresh token"prompt": "consent",
})
returnredirect(f"{GOOGLE_AUTH_URL}?{params}")
@app.route("/auth/google/callback")
defgoogle_callback():
# Verify state parameterif request.args.get("state") != session.pop("oauth_state", None):
return"Invalid state", 403
# Exchange code for tokens
token_response = requests.post(GOOGLE_TOKEN_URL, data={
"code": request.args["code"],
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": url_for("google_callback", _external=True),
"grant_type": "authorization_code",
})
tokens = token_response.json()
# Fetch user profile using the access token
user_response = requests.get(GOOGLE_USERINFO_URL, headers={
"Authorization": f"Bearer {tokens['access_token']}",
})
user_info = user_response.json()
# Create or update user in your database
session["user"] = {
"id": user_info["sub"],
"email": user_info["email"],
"name": user_info["name"],
"picture": user_info["picture"],
}
returnredirect("/dashboard")Two gotchas specific to Google. access_type: "offline" is required to get a refresh token. Without it, you only get an access token. And Google only issues a refresh token on the first authorization. If the user has already authorized your app, subsequent logins skip the consent screen and return no refresh token. prompt: "consent" forces the consent screen every time. Annoying for the user, but it is the reliable way to ensure you get a refresh token back.
GitHub OAuth
Simpler. No OIDC, just OAuth 2.0. Access token, then call the GitHub API for user info.
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
app.get("/login/github", (req, res) => {
const state = crypto.randomBytes(32).toString("hex");
req.session.oauthState = state;
const params = newURLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: "https://myapp.com/auth/github/callback",
scope: "read:user user:email",
state: state,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
});
app.get("/auth/github/callback", async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(403).send("State mismatch");
}
delete req.session.oauthState;
// Exchange code for access tokenconst tokenRes = awaitfetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code: code,
}),
});
const { access_token } = await tokenRes.json();
// Fetch user profile from GitHub APIconst userRes = awaitfetch("https://api.github.com/user", {
headers: { "Authorization": `Bearer ${access_token}` },
});
const user = await userRes.json();
// Fetch email separately (might be private)const emailRes = awaitfetch("https://api.github.com/user/emails", {
headers: { "Authorization": `Bearer ${access_token}` },
});
const emails = await emailRes.json();
const primaryEmail = emails.find(e => e.primary)?.email;
// Store user in session
req.session.user = {
id: user.id,
username: user.login,
name: user.name,
email: primaryEmail,
avatar: user.avatar_url,
};
res.redirect("/dashboard");
});The /user endpoint returns null for email if the user has set it to private. Store that null in a column with a unique constraint and the next user with a private email cannot sign up either. Separate request to /user/emails with the user:email scope. Test this before launch.
When supporting multiple providers, link accounts by email. User signs up with Google, later logs in with GitHub using the same email -- same account. But only on verified emails. GitHub emails can be unverified. Trusting unverified emails for account linking is an account takeover vulnerability, and it is one of those bugs that is trivial to exploit and hard to notice in testing because testers always use their real email.
Common Security Mistakes
1. Storing Tokens in localStorage
Any JavaScript on your page can read localStorage. One XSS vulnerability and the attacker has your tokens. Popular open-source starter templates ship with refresh tokens in localStorage by default. HTTP-only cookies for refresh tokens. Access tokens in memory. Page refresh? Call the refresh endpoint silently.
2. Skipping the State Parameter
No state means your callback is open to CSRF. An attacker crafts a callback URL that links the victim's account to the attacker's identity. Crypto-random state, session-stored, verified on callback.
3. Not Validating the Redirect URI
Wildcards or partial matches on redirect URIs let an attacker register their own server as a redirect target and intercept the authorization code. Most providers enforce exact matching by default. If you run your own authorization server, do the same.
4. Using the Implicit Flow
Tokens in the URL fragment. Visible in browser history, referrer headers, server logs. OAuth 2.1 formally deprecates this. Dead pattern.
5. Not Rotating Refresh Tokens
Stolen refresh token generates access tokens forever. Rotation: each use invalidates the old token and issues a new one. Attacker tries the stolen token after the real user has already refreshed? Server detects reuse, revokes everything.
6. Trusting Unvalidated JWTs
JWTs are base64-encoded, not encrypted. Anyone can forge one that looks right on the surface. Validate on the server. Pin the algorithm.
7. Overly Broad Scopes
Requesting repo access on GitHub when you only need read:user. Red flag for users. Liability if tokens leak. Request the minimum. Add scopes when you add the feature that needs them, not before.
// Secure cookie settings for refresh tokensconst COOKIE_OPTIONS = {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: "strict", // Prevents CSRF via cross-site requests
path: "/auth", // Only sent to auth endpoints
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};
// Set refresh token as HTTP-only cookie after login
app.post("/auth/token", async (req, res) => {
const tokens = awaitexchangeCodeForTokens(req.body.code);
// Refresh token goes in HTTP-only cookie
res.cookie("refresh_token", tokens.refresh_token, COOKIE_OPTIONS);
// Access token goes in the response body (kept in memory by SPA)
res.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
});
});
// Silent refresh endpoint using HTTP-only cookie
app.post("/auth/refresh", async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: "No refresh token" });
}
try {
const tokens = awaitrefreshAccessToken(refreshToken);
// Rotate the refresh token
res.cookie("refresh_token", tokens.refresh_token, COOKIE_OPTIONS);
res.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
});
} catch (err) {
res.clearCookie("refresh_token");
res.status(401).json({ error: "Refresh failed" });
}
});Access token in memory: safe from CSRF, vulnerable to XSS only while the tab is open. Refresh token in an HTTP-only cookie: safe from XSS, protected from CSRF by SameSite and the restricted /auth path. Page refresh? SPA hits /auth/refresh, reads the cookie, gets a new access token. Both threat models covered. No token in persistent browser storage.
OAuth 2.1 is merging the security best practices into the spec itself -- PKCE required, no implicit flow, no password grant. When it ships, half this article becomes "just follow the spec." But until then, the gap between the spec and the security requirements is where the vulnerabilities live. Authorization code with PKCE, refresh tokens in HTTP-only cookies, full JWT validation on the server. Or use Auth0 or Keycloak and let someone else deal with key rotation, token revocation, and account linking. Building auth from scratch is a multi-month project that never feels finished.