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 plenty of tutorials 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. Mobile apps can keep tokens in the platform's secure storage. A browser has no equivalent, so for SPAs the current guidance (RFC 10017, OAuth 2.0 for Browser-Based Applications) is to add a small backend: a backend-for-frontend (BFF) that keeps every token server-side behind a session cookie, or a token-mediating backend that keeps the refresh token in an HTTP-only cookie and hands the SPA only short-lived access tokens to hold 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.

JavaScript - Authorization Code Flow (Express)
const express = require("express");
const session = require("express-session");
const cookieParser = require("cookie-parser");
const crypto = require("crypto");

const app = express();
app.set("trust proxy", 1); // behind nginx or a load balancer, so secure cookies work
app.use(cookieParser());
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  // "lax", not "strict": the cookie must be sent on the redirect back from the provider
  cookie: { httpOnly: true, secure: true, sameSite: "lax" },
  // The default MemoryStore is for development only. Use Redis or a database store.
}));

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";

// POST to the token endpoint and fail loudly on any error
async function tokenRequest(params) {
  const response = await fetch(TOKEN_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      "Accept": "application/json",
    },
    body: new URLSearchParams({
      ...params,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });

  const body = await response.json().catch(() => ({}));
  if (!response.ok || body.error || !body.access_token) {
    throw new Error(`Token request failed: ${body.error || response.status}`);
  }
  return body; // { access_token, expires_in, token_type, refresh_token? }
}

// Step 1: Redirect user to authorization server
app.get("/auth/login", (req, res) => {
  const state = crypto.randomBytes(32).toString("hex");
  // PKCE is recommended for confidential clients too (RFC 9700)
  const codeVerifier = crypto.randomBytes(32).toString("base64url");
  const codeChallenge = crypto.createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");

  req.session.oauthState = state;
  req.session.codeVerifier = codeVerifier;

  const params = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: "openid profile email",
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  });

  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;
  const { oauthState, codeVerifier } = req.session;
  delete req.session.oauthState; // one-time values
  delete req.session.codeVerifier;

  // Verify state to prevent CSRF
  if (!oauthState || state !== oauthState) {
    return res.status(403).send("Invalid state parameter");
  }
  if (!code) {
    return res.status(400).send("Login was cancelled or failed");
  }

  try {
    const tokens = await tokenRequest({
      grant_type: "authorization_code",
      code: code,
      redirect_uri: REDIRECT_URI,
      code_verifier: codeVerifier,
    });

    // Store tokens in the server-side session (never in the browser)
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    // expires_in is in seconds; assume a short lifetime if the provider omits it
    req.session.tokenExpiresAt = Date.now() / 1000 + (tokens.expires_in ?? 300);

    res.redirect("/dashboard");
  } catch (err) {
    console.error(err);
    res.status(502).send("Login failed");
  }
});

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.

JavaScript - PKCE Flow (Browser/SPA)
// Generate a cryptographically random code verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// Create the SHA-256 code challenge from the verifier
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return btoa(String.fromCharCode(...new Uint8Array(hash)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

// Step 1: Start the login flow
async function startLogin() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = crypto.randomUUID();

  // Store verifier and state for later verification
  sessionStorage.setItem("pkce_verifier", codeVerifier);
  sessionStorage.setItem("oauth_state", state);

  const params = new URLSearchParams({
    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 verifier
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get("code");
  const state = params.get("state");

  if (state !== sessionStorage.getItem("oauth_state")) {
    throw new Error("State mismatch -- possible CSRF attack");
  }

  const codeVerifier = sessionStorage.getItem("pkce_verifier");

  const response = await fetch("https://provider.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      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();
  if (!response.ok || tokens.error) {
    throw new Error(`Token exchange failed: ${tokens.error || response.status}`);
  }

  // Clean up stored values
  sessionStorage.removeItem("pkce_verifier");
  sessionStorage.removeItem("oauth_state");

  return tokens;
}

This is the pure-browser version: the SPA talks to the token endpoint itself, so the tokens end up in JavaScript. That works with providers that allow public clients, but anything stored in the page is readable by an XSS payload, and there is nowhere safe to put a refresh token. For sensitive apps, run the same PKCE flow from a BFF or token-mediating backend (see the last code example) instead.

Use PKCE on the server too. RFC 9700, the OAuth 2.0 Security Best Current Practice (January 2025), recommends it for confidential clients, and the OAuth 2.1 draft makes it required. The Express example above already sends a code_challenge. It protects against authorization code injection, and it still helps 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.

JavaScript - Token Refresh (Express Middleware)
// Reuses tokenRequest() from the first example
async function refreshAccessToken(refreshToken) {
  return tokenRequest({
    grant_type: "refresh_token",
    refresh_token: refreshToken,
  });
}

// Middleware to ensure valid access token on every request
async function ensureAuthenticated(req, res, next) {
  if (!req.session.accessToken) {
    return res.redirect("/auth/login");
  }

  // Check if the access token is about to expire (set in the callback)
  const expiresAt = req.session.tokenExpiresAt || 0;
  const bufferSeconds = 60; // Refresh 60 seconds before expiry

  if (Date.now() / 1000 >= expiresAt - bufferSeconds) {
    if (!req.session.refreshToken) {
      return req.session.destroy(() => res.redirect("/auth/login"));
    }
    try {
      const tokens = await refreshAccessToken(req.session.refreshToken);
      req.session.accessToken = tokens.access_token;
      req.session.tokenExpiresAt = Date.now() / 1000 + (tokens.expires_in ?? 300);

      // Rotate refresh token if the server issues a new one
      if (tokens.refresh_token) {
        req.session.refreshToken = tokens.refresh_token;
      }
    } catch (err) {
      // Refresh failed -- force re-login
      return req.session.destroy(() => 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.

Python - Access Token Validation (Flask API)
import jwt
from flask import Flask, request, jsonify

app = Flask(__name__)

# Access tokens issued by YOUR authorization server (Keycloak, Auth0, ...)
# for YOUR API. Do not accept Google or GitHub ID tokens here.
ISSUER = "https://auth.example.com/realms/myapp"
AUDIENCE = "https://api.example.com"
JWKS_URI = "https://auth.example.com/realms/myapp/protocol/openid-connect/certs"

# Caches the signing keys and refetches when it sees an unknown key ID
# (with a cooldown in PyJWT 2.14+). The timeout stops a slow JWKS endpoint
# from hanging your requests.
jwks_client = jwt.PyJWKClient(JWKS_URI, cache_keys=True, timeout=5)


def validate_jwt(token):
    """Validate a JWT with full security checks."""
    # Step 1: Find the public key that matches the token's "kid" header
    signing_key = jwks_client.get_signing_key_from_jwt(token)

    # Step 2: Verify signature, issuer, audience, and expiration
    return jwt.decode(
        token,
        key=signing_key,
        algorithms=["RS256"],
        issuer=ISSUER,
        audience=AUDIENCE,
        options={
            "require": ["exp", "iss", "aud", "sub"],
        },
    )


@app.route("/api/profile")
def get_profile():
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return jsonify({"error": "Missing bearer token"}), 401

    token = auth_header[7:]
    try:
        claims = validate_jwt(token)
    except jwt.ExpiredSignatureError:
        return jsonify({"error": "Token expired"}), 401
    except jwt.PyJWTError:
        # Bad signature, wrong issuer/audience, unknown key, JWKS fetch failure...
        return jsonify({"error": "Invalid token"}), 401

    return jsonify({
        "user_id": claims["sub"],
        "scope": claims.get("scope"),
    })

First, which token belongs here. A resource server validates access tokens that your authorization server issued for your API, with the API as the audience. An OpenID Connect ID token (like the one Google returns at login) tells your client who signed in. Its audience is your client ID, and it should not be sent to your API as a bearer token. Google and GitHub access tokens are meant for their APIs, not yours.

The JWKS endpoint gives you the authorization server's public signing keys. PyJWKClient caches them and handles rotation: an unknown key ID triggers a refetch. From PyJWT 2.14 it applies a cooldown, so tokens with random kid values cannot force a JWKS download on every request. On older versions, add your own rate limit. The timeout keeps a slow JWKS endpoint from hanging your workers. 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.

Python - Google OAuth Login (Flask)
import os, secrets, requests
import jwt
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_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs"
# Google ID tokens use either form of the issuer
GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"]

google_jwks = jwt.PyJWKClient(GOOGLE_JWKS_URI, cache_keys=True, timeout=5)


@app.route("/login/google")
def google_login():
    state = secrets.token_urlsafe(32)
    nonce = secrets.token_urlsafe(32)
    session["oauth_state"] = state
    session["oauth_nonce"] = nonce

    params = urlencode({
        "client_id": GOOGLE_CLIENT_ID,
        "redirect_uri": url_for("google_callback", _external=True),
        "response_type": "code",
        "scope": "openid email profile",
        "state": state,
        "nonce": nonce,  # echoed back inside the ID token
        "access_type": "offline",  # Request a refresh token
        "prompt": "consent",
    })
    return redirect(f"{GOOGLE_AUTH_URL}?{params}")


@app.route("/auth/google/callback")
def google_callback():
    expected_state = session.pop("oauth_state", None)
    expected_nonce = session.pop("oauth_nonce", None)

    # Verify state parameter
    if not expected_state or request.args.get("state") != expected_state:
        return "Invalid state", 403
    if "code" not in request.args:
        return "Login was cancelled or failed", 400

    # 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",
    }, timeout=10)
    if not token_response.ok:
        return "Token exchange failed", 502
    tokens = token_response.json()

    # Verify the ID token: signature, audience, issuer, expiry, then the nonce
    id_token = tokens["id_token"]
    try:
        claims = jwt.decode(
            id_token,
            key=google_jwks.get_signing_key_from_jwt(id_token),
            algorithms=["RS256"],
            audience=GOOGLE_CLIENT_ID,
            issuer=GOOGLE_ISSUERS,
            options={"require": ["exp", "iss", "aud", "sub", "nonce"]},
        )
    except jwt.PyJWTError:
        return "Invalid ID token", 403
    if not expected_nonce or not secrets.compare_digest(claims["nonce"], expected_nonce):
        return "Invalid nonce", 403

    # tokens.get("refresh_token") belongs in your database, not in Flask's
    # default session, which is a signed (not encrypted) browser cookie.

    session["user"] = {
        "id": claims["sub"],
        # Only trust the address if Google says it is verified
        "email": claims.get("email") if claims.get("email_verified") else None,
        "name": claims.get("name"),
        "picture": claims.get("picture"),
    }

    return redirect("/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. Only add it to the login that actually needs offline access.

The nonce is the OpenID Connect counterpart to state. Google copies it into the ID token, and checking it ties that token to this browser session. The callback verifies the ID token itself (signature, audience, both forms of Google's issuer, expiry, nonce) instead of trusting whatever the userinfo endpoint returns, and it only uses the email when email_verified is true. You can add PKCE here as well: Google supports code_challenge for web server apps.

GitHub OAuth

Simpler. No OIDC, just OAuth 2.0. Access token, then call the GitHub API for user info.

JavaScript - GitHub OAuth Login (Express)
// Continues the Express app from the first example (session + crypto)
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 = new URLSearchParams({
    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;
  const expectedState = req.session.oauthState;
  delete req.session.oauthState;

  if (!expectedState || state !== expectedState) {
    return res.status(403).send("State mismatch");
  }

  // Exchange code for access token
  const tokenRes = await fetch("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 tokenData = await tokenRes.json();

  // GitHub answers HTTP 200 with an "error" field for bad or expired codes
  if (!tokenRes.ok || tokenData.error || !tokenData.access_token) {
    return res.status(502).send("GitHub token exchange failed");
  }

  const ghHeaders = {
    "Authorization": `Bearer ${tokenData.access_token}`,
    "Accept": "application/vnd.github+json",
  };

  // Fetch user profile and email addresses (email may be private on /user)
  const [userRes, emailRes] = await Promise.all([
    fetch("https://api.github.com/user", { headers: ghHeaders }),
    fetch("https://api.github.com/user/emails", { headers: ghHeaders }),
  ]);
  if (!userRes.ok || !emailRes.ok) {
    return res.status(502).send("GitHub API request failed");
  }
  const user = await userRes.json();
  const emails = await emailRes.json();

  // Only a verified primary address is safe to use for account linking
  const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? null;

  // 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. Code that assumes an email is always present breaks here: a NOT NULL column rejects the sign-up, and code that falls back to an empty string will collide on a unique constraint for the second private-email user. (Plain NULL is fine in a unique column in PostgreSQL, MySQL and SQLite, but SQL Server allows only one.) Make a separate request to /user/emails with the user:email scope and handle users with no verified address. Also note that GitHub returns HTTP 200 with an error field for a bad or expired code, which is why the example checks the body, not just the status. 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, which is why the example requires e.verified as well as e.primary. 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. Check any starter template you adopt for this. 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. RFC 9700 says clients should not use it, and the OAuth 2.1 draft leaves it out entirely. 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.

JavaScript - Token-Mediating Backend for an SPA (Express)
// Token-mediating backend for an SPA. The server runs the code flow from the
// first example; the browser only ever sees short-lived access tokens.
const COOKIE_OPTIONS = {
  httpOnly: true, // Not accessible via JavaScript
  secure: true, // Only sent over HTTPS
  sameSite: "strict", // Not sent on cross-site requests
  path: "/auth", // Only sent to auth endpoints
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
};

// Call this in /auth/callback instead of keeping the refresh token in the session
function setRefreshCookie(res, tokens) {
  // Providers that do not rotate refresh tokens omit refresh_token on refresh:
  // keep the existing cookie instead of overwriting it with "undefined"
  if (tokens.refresh_token) {
    res.cookie("refresh_token", tokens.refresh_token, COOKIE_OPTIONS);
  }
}

// Silent refresh endpoint: the SPA calls this on load and before expiry
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 = await refreshAccessToken(refreshToken);
    setRefreshCookie(res, tokens); // only replaced when the provider rotated it

    // Access token goes in the response body (kept in memory by the SPA)
    res.json({
      access_token: tokens.access_token,
      expires_in: tokens.expires_in,
    });
  } catch (err) {
    // clearCookie needs the same path the cookie was set with
    res.clearCookie("refresh_token", { path: COOKIE_OPTIONS.path });
    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: script cannot read it, SameSite=strict keeps it off cross-site requests, and the /auth path keeps it out of every other request. An XSS payload can still call /auth/refresh while the page is open, which is why RFC 10017 prefers a full BFF for sensitive apps. Page refresh? SPA hits /auth/refresh, reads the cookie, gets a new access token. Both threat models covered. No token in persistent browser storage.

What to Use Today, and What's Changing

OAuth 2.1 is merging the security best practices into the spec itself — PKCE required, no implicit flow, no password grant. It is still an IETF draft (revision -16, September 2026), so the normative guidance today is RFC 9700, plus RFC 10017 for browser apps. When 2.1 is published, much of 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 and maintaining auth yourself is an ongoing commitment, not a one-off task.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.