#React#DevOps

OAuth 2.0 PKCE and Token Rotation

The implicit flow is unsafe for SPAs and mobile apps; PKCE is now standard. This article explains PKCE (`code_verifier`/`code_challenge`), why it replaces the implicit flow, access and refresh token lifecycles, token rotation and revocation, and a production-grade OAuth client implementation.

Key Points

  • Topic Focus: React、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I have reviewed OAuth implementations from many teams and noticed a common pattern: everyone can get "Sign in with GitHub" working. They copy an .env configuration, connect NextAuth, confirm that the login button works and a token comes back, and consider the job done. But ask whether their PKCE uses S256 or plain, whether their refresh tokens rotate, or where the access token is stored, and most become vague.

This is not the developer's fault. OAuth carries too much historical baggage. RFC 6749, published in 2012, pushed SPAs and mobile apps toward the "implicit flow" and "password flow," two options that were fundamentally insecure. It took almost another decade for PKCE and OAuth 2.1 to close those gaps. This article explains the entire subject: exactly how PKCE prevents authorization code interception, why the implicit flow must die, how to design the lifecycles of both tokens, how to implement refresh token rotation, how to build revocation blacklists, and finally how to configure production-grade PKCE with NextAuth/Auth.js.

By the end, you will have a set of criteria you can apply directly in production instead of merely copying another .env file.

1. The Problem PKCE Solves: Authorization Code Interception

Start with the Authorization Code Flow itself. It exchanges a code for tokens in two steps:

text
Step 1  Browser → Authorization server: redirect; user signs in and consents
        Authorization server → Browser: redirect back to redirect_uri with authorization code
Step 2  Client backend → Authorization server: code + client_secret → access_token

Why take this detour instead of returning a token directly? Because the redirect travels through the browser, which is an untrusted channel. Data in a URL can be captured by browser history, Referer headers, reverse proxy logs, and browser extensions. The code is a short, single-use value that lives for only tens of seconds. Even if intercepted, it can be exchanged only once and must be accompanied by client_secret. The client_secret exists only on the server and is never available to the frontend. The Authorization Code Flow therefore assumes that you have a backend capable of protecting client_secret.

Here is the problem: a frontend-only SPA or mobile app has no backend, and even when a backend exists, the client itself is still "public." Any "secret" compiled into an app can be extracted through decompilation, and any secret bundled into frontend code is visible in DevTools. Such applications are called public clients, and they cannot securely hold a static secret.

Without client_secret, half of the protection offered by the two-step code exchange disappears. If an attacker intercepts the code from the redirect, perhaps by intercepting an Android custom-scheme Intent, redirecting an SPA to a malicious page, or reading browser history, the attacker can exchange it for a token before the legitimate client does. This is an Authorization Code Interception Attack, described specifically in OAuth security RFC 6819.

PKCE (Proof Key for Code Exchange, RFC 7636) was designed for this scenario. It was originally intended for mobile apps, later proved equally effective for SPAs, and is now mandatory for all public clients.

2. How PKCE Works: A Dynamic Handshake Between code_verifier and code_challenge

The central idea behind PKCE is simple: replace the static client_secret with a one-time dynamic secret. Each time authorization begins, the client generates a temporary secret that exists only in client memory. The authorization server receives only its hash, not the original value. When exchanging the code for tokens, the client reveals the original value. The server hashes it and issues tokens only if the values match.

The dynamic secret is called code_verifier, and its hash is called code_challenge. The complete flow is:

text
Client (before starting authorization)
  1. Generate code_verifier: a high-entropy random string 43~128 characters long
  2. Calculate code_challenge = BASE64URL( SHA256( code_verifier ) )   ← method=S256
  3. Redirect to the authorization server with code_challenge + code_challenge_method=S256

Authorization server
  4. Record that this authorization session is bound to this code_challenge

After the user signs in and consents, redirect back with the code

Client (when exchanging the code for tokens)
  5. Exchange code + the original code_verifier for tokens

Authorization server
  6. Recalculate BASE64URL(SHA256(code_verifier)) and compare it with the challenge recorded in step 3
  7. Match → issue tokens; mismatch → reject

The critical detail is that code_verifier is sent only in the final direct token request and is used only once. If an attacker intercepts only the code in the redirect, which is the easiest part to intercept, the attacker cannot provide code_verifier when exchanging the code, so the server rejects the request. The attacker would have to intercept both the code and code_verifier. But code_verifier remains in memory and never passes through public channels such as URLs, browser history, or Referer headers.

That is the essence of PKCE: replace a static key with a one-time dynamic proof, making code interception useless.

S256 vs. plain: Always Use S256

code_challenge_method has two possible values:

  • plain: code_challenge = code_verifier without transformation. This exists only for clients too computationally limited to perform SHA256 and provides no meaningful protection. Its presence in RFC 7636 is purely historical compatibility baggage.
  • S256: code_challenge = BASE64URL(SHA256(code_verifier)). This is the only practical engineering choice.

Always use S256 in production. If your IdP does not support S256, replace the IdP. Every modern authorization server, including Auth0, Keycloak, Authing, Logto, Google, GitHub, and Microsoft, supports it.

Implement PKCE Once to Understand What Actually Happens

You are unlikely to implement this manually in production because a library should handle it, but reading the raw code once makes PKCE much less of a black box:

js
// pkce.js — manual PKCE implementation for understanding the mechanism
import { randomBytes, createHash } from 'node:crypto';

// 1. Generate code_verifier: 43~128 characters of high-entropy random data encoded as base64url
export function generateCodeVerifier() {
  return randomBytes(32).toString('base64url'); // 43 characters, exactly the minimum length
}

// 2. Derive the challenge from the verifier using S256
export function computeCodeChallenge(verifier) {
  return createHash('sha256').update(verifier).digest('base64url');
}

// 3. Before authorization, store the verifier in memory/sessionStorage, not localStorage
//    Put the challenge in the authorization URL
export function buildAuthorizeUrl(authEndpoint, params, verifier) {
  const challenge = computeCodeChallenge(verifier);
  const url = new URL(authEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', params.clientId);
  url.searchParams.set('redirect_uri', params.redirectUri);
  url.searchParams.set('scope', params.scope);
  url.searchParams.set('state', params.state);          // Required for CSRF protection
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  return url.toString();
}

// 4. When exchanging the code, send the original verifier with it
export async function exchangeCodeForToken(tokenEndpoint, payload) {
  const res = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: payload.code,
      redirect_uri: payload.redirectUri,
      client_id: payload.clientId,
      code_verifier: payload.verifier,  // ← Critical: the original value, not the hash
    }),
  });
  return res.json(); // { access_token, refresh_token?, id_token?, expires_in }
}

Note several details. The state parameter prevents CSRF; it serves a different purpose from PKCE, and neither replaces the other. Keeping code_verifier in memory or sessionStorage is sufficient. It will disappear after a page refresh, but the authorization flow is meant to complete in one pass. Do not store it in localStorage, where XSS could steal it. The token exchange uses application/x-www-form-urlencoded, not JSON, because that is the longstanding OAuth convention.

3. Why the Implicit Flow Must Die

Once you understand PKCE, you can understand why the OAuth 2.0 Implicit Flow is already a historical term in 2026.

RFC 6749 introduced the implicit flow in 2012 for frontend-only SPAs. At the time, an SPA had no backend and could not store client_secret, so it could not run the Authorization Code Flow. The committee therefore created a shortcut: skip the token exchange and have the authorization server return the access token directly to the frontend in the URL fragment (#access_token=...). There was no code and no secret; everything happened in one step.

text
Implicit Flow:
  Browser → Authorization server: response_type=token
  Authorization server → Browser: redirect_uri#access_token=eyJhbG...&expires_in=3600
  Frontend JS extracts the token from location.hash and stores it

It sounds convenient but is disastrous in practice. There are three fatal problems.

First, the token is exposed directly in the URL fragment. Although the fragment, the part after #, is not sent to the server and does not enter access logs, it does enter browser history, can be read by same-origin third-party scripts, and can be intercepted by malicious browser extensions. The Authorization Code Flow added an extra step precisely because of this risk; the implicit flow removes that protection.

Second, there is no refresh token. The implicit flow does not return a refresh token because there is no safe way to authenticate the client, so the authorization server will not issue a long-lived credential. When the access token expires, usually after one hour, the user must repeat the login and authorization flow, disrupting the experience. To avoid repeated logins, many implementations extend the access token's lifetime, violating the principle that access tokens must be short-lived.

Third, the client cannot prove its identity. Nothing in the flow confirms that "the application receiving the token is your application." Anyone who intercepts the token can use it directly, and you cannot even determine which client leaked it.

All three problems share the same root cause: the implicit flow transmits a long-lived credential through an insecure channel while omitting all client authentication. The OAuth 2.0 Security Best Current Practice (RFC 9700, whose predecessor was BCP 212) explicitly requires clients to stop using the implicit flow, and the OAuth 2.1 draft removes it from the specification entirely. Modern browser privacy policies, including Safari's ITP, are also increasingly unfriendly to tokens in fragments.

The correct replacement is Authorization Code Flow + PKCE. PKCE solves the public client's lack of a secret, while the two-step Authorization Code Flow restores security and the ability to use refresh tokens. In 2026, this combination is the only recommended approach for public clients, without exception. Mobile apps, desktop apps, and SPAs should all use it. If your project still contains response_type=token, treat it as technical debt and schedule its removal.

4. Access Tokens and Refresh Tokens: Dividing Short-Term and Long-Term Responsibilities

Once PKCE works, you can obtain tokens safely. But the two tokens have completely different lifetimes and must be treated differently by design.

Why Two Tokens Are Necessary

With only one token, you face an impossible trade-off:

  • Short lifetime (15 minutes) → secure, but the user must sign in again every hour, ruining the experience.
  • Long lifetime (30 days) → convenient, but if leaked, an attacker has a 30-day window and you have no recourse.

The two-token design breaks this trade-off:

Access TokenRefresh Token
PurposeCall business APIs on behalf of the user's authorizationUsed only to obtain a new access token
LifetimeShort, from 15 minutes to 1 hourLong, from several days to several dozen days
FrequencySent with every requestUsed once only when the access token expires
Impact of leakageShort exposure window and limited damageEquivalent to a long-term key and must be heavily protected
Revocation difficultyHard for stateless JWTs, easy with session lookupEasy because the server controls requests for new tokens

The core idea is to separate frequent use from long validity: make the frequently used token short-lived and use the long-lived token infrequently. If an access token is stolen during frequent transmission, the attacker has only a window of several minutes. A refresh token lives longer but crosses the network only during occasional refreshes, greatly reducing its exposure.

Choosing Token Lifetimes

Practical ranges are:

  • Access Token: 15 minutes to 1 hour. I prefer shorter lifetimes, usually 15 to 30 minutes, especially for sensitive applications. A short lifetime is the strongest mitigation for difficult JWT revocation.
  • Refresh Token: 7 to 30 days. Consumer products can use 30 days with sliding renewal so active users remain signed in. Business administration systems are safer with 1 to 7 days.
  • Absolute vs. sliding expiration: Sliding expiration, which extends the deadline after every refresh, improves the experience but increases risk. Absolute expiration, which requires a new login after 30 days regardless of refresh activity, is safer. A compromise is sliding renewal with an absolute ceiling, such as allowing extensions for at most 60 days before requiring reauthentication.

Refresh Tokens Must Remain on the Server

This is a hard rule: a refresh token must never appear in business API requests or be read and written frequently by frontend JavaScript. The correct design is:

  • Store the refresh token in an httpOnly + Secure + SameSite cookie so frontend JavaScript cannot read it.
  • Store the access token in memory, such as a JavaScript variable, or in a short-lived cookie.
  • When the access token expires, the frontend calls an /api/auth/refresh endpoint. The backend reads the refresh token from the cookie, exchanges it with the IdP for a new access token, and returns that token to the frontend. The refresh token never passes through JavaScript.

With this design, XSS may steal an access token but can use it for only several minutes. It cannot steal the httpOnly-protected refresh token and therefore cannot extend the session. Many teams put the refresh token in localStorage for convenience. That is equivalent to hanging a 30-day key on the wall for XSS to take.

5. Token Rotation: Implementing Refresh Token Rotation

At this point, the refresh token still poses one remaining risk: it has a long lifetime. If it leaks through interception, extraction from a cookie, or loss of a device, you may have no recourse for 30 days.

Refresh Token Rotation is the engineering response to this risk. The idea is: whenever a refresh token is exchanged for a new access token, the authorization server also issues a new refresh token and immediately invalidates the old one.

text
Without rotation:
  refresh_token_R1 → exchange for access_token
  Later, refresh_token_R1 → exchange for access_token again   ← The same refresh token is reused; leakage creates long-term compromise

With rotation:
  refresh_token_R1 → exchange for access_token + refresh_token_R2 (R1 becomes invalid immediately)
  refresh_token_R2 → exchange for access_token + refresh_token_R3 (R2 becomes invalid immediately)
  ...

How Rotation Detects Token Theft

This is the real value of rotation. It does more than merely issue a replacement token.

Suppose an attacker steals R2, the legitimate user's refresh token. The following happens:

text
Legitimate user: R2 → receives access + R3   (R2 is now invalid, but the attacker still has a copy of R2)
Attacker: R2 → tries to exchange it?         (R2 is invalid, so the server rejects it)

The attacker can no longer use it. But rotation can go further by detecting that R2 was reused and inferring that the token was stolen:

text
Legitimate user: R2 → receives access + R3
Attacker (too slow): R2 → tries to exchange it   ← R2 already produced R3 once and has now appeared a second time

Server detects: R2 is a refresh token that has already been consumed, yet it was submitted again!
         → The refresh token chain must have forked → someone stole it
         → Immediately revoke the entire chain, including R3, R4, and so on, and force the user to sign in again

This is Refresh Token Reuse Detection. It upgrades token rotation from a measure that merely reduces the exposure window into an alarm that actively detects theft. The OAuth 2.0 Security BCP lists rotation plus reuse detection as the recommended default behavior for public clients.

Costs and Boundaries of Rotation

Rotation is not free:

  • Every refresh writes to storage by updating the refresh token's state and invalidating the old token. In high-frequency refresh scenarios, this puts write pressure on token storage, so use Redis rather than a database.
  • Concurrent refresh races can occur when multiple tabs for the same user notice that the access token has expired and call /api/auth/refresh simultaneously. The first request succeeds and rotates R1 into R2, while the second presents R1 and is rejected. Solve this by deduplicating refresh requests on the frontend, sharing one refresh Promise across tabs, or by having the authorization server provide a short grace period during which R1 may be accepted once more and produces the same R2.
  • Not every IdP supports reuse detection. Auth0, Authing, newer versions of Keycloak, and Logto support it. A custom OAuth server must implement it explicitly.
  • A confidential client with a backend secret may not need rotation. If the refresh token is locked inside a server-side session and never crosses a public channel, rotation provides less marginal benefit. Rotation is primarily for public clients.

A Minimal Working Rotation Implementation

If you operate your own token service, the core rotation logic takes only a few dozen lines:

js
// tokenRotation.js — server-side refresh + rotation + reuse detection
import { randomBytes } from 'node:crypto';
import { redis } from './redis';

const REFRESH_TTL = 60 * 60 * 24 * 30; // 30 days
const CHAIN_PREFIX = 'refresh_chain:';

export async function rotateRefreshToken(oldRefreshToken, userId) {
  // 1. Look up the old token's status
  const tokenKey = `rtok:${oldRefreshToken}`;
  const meta = await redis.hgetall(tokenKey);

  if (!meta || meta.status !== 'active') {
    // 1a. This token either does not exist or has already been rotated (status=rotated)
    if (meta && meta.status === 'rotated') {
      // ⚠ Reuse detection: a rotated token was submitted again → the chain was stolen
      // Revoke the entire chain immediately and force the user to sign in again
      await redis.del(`${CHAIN_PREFIX}${meta.chainId}`);
      throw new TokenTheftError('refresh token reuse detected, chain revoked');
    }
    throw new InvalidTokenError('refresh token not found');
  }

  // 2. Issue a new access token and a new refresh token
  const newAccessToken = signAccessToken(userId, /* ttl */ 15 * 60);
  const newRefreshToken = randomBytes(32).toString('base64url');

  // 3. Mark the old token as rotated; retain it briefly for reuse detection, then expire it
  await redis.hset(tokenKey, 'status', 'rotated');
  await redis.expire(tokenKey, 60 * 60); // Remove after 1 hour

  // 4. Store the new token and attach it to the same chain
  await redis.hset(`rtok:${newRefreshToken}`, {
    userId,
    chainId: meta.chainId,
    status: 'active',
  });
  await redis.expire(`rtok:${newRefreshToken}`, REFRESH_TTL);

  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Three details are central: mark the old token as rotated instead of deleting it so reuse can be detected, attach the new token to the same chainId so the whole chain can be revoked, and destroy the entire chain when reuse is detected. Commercial IdPs such as Auth0 use essentially this logic internally, and you can follow the same pattern when building your own.

6. Revocation and Blacklists: Engineering Immediate Token Invalidation

A user signs out, changes their password, gets suspended, or triggers refresh token theft detection. All these events require tokens to become invalid immediately rather than waiting for natural expiration. This section explains how to implement that behavior.

The Two Token Types Have Very Different Revocation Costs

  • Revoking a refresh token is easy: Its purpose is to ask the server for a new token. Mark it invalid on the server, and reject its next exchange request. It is inherently stateful, so revocation is simply deleting or updating a record.
  • Revoking an access token is difficult: This is especially true for a JWT, which is stateless and self-contained. The server verifies the signature and trusts the payload without checking a central validity record. Until exp, the JWT itself remains "valid."

The practical strategy is therefore to accept that an access token cannot truly be revoked before natural expiration, minimize that window with a 15-to-30-minute lifetime, and strongly revoke the refresh token to prevent renewal. Signing out actually means clearing the frontend access token and revoking the server-side refresh token. If an attacker still holds the access token, they can use it until it expires, but only for several minutes.

Three Engineering Approaches to JWT Revocation

If you must revoke an access token before expiration, such as after detecting an attack or suspending an account, there are three increasingly expensive options.

1. Use a short exp and do nothing else. This is the most common and simplest choice. Accept that revocation can be delayed by up to 15 minutes. This is sufficient for most consumer products.

2. Use a blacklist. Maintain a Redis list of token IDs that have been revoked but have not yet expired. After signature verification, perform one additional lookup and reject blacklist matches.

js
// blacklist.js — JWT blacklist middleware
export async function authGuard(req, res, next) {
  const token = bearerOf(req);
  const payload = verifyJwt(token);             // Verify signature + exp
  const blacklisted = await redis.get(`bl:${payload.jti}`);
  if (blacklisted) return res.status(401).end();
  req.user = payload;
  next();
}

export function revokeToken(payload) {
  // Blacklist TTL = token's remaining lifetime; remove it automatically after expiration
  const ttl = payload.exp - Math.floor(Date.now() / 1000);
  if (ttl > 0) redis.set(`bl:${payload.jti}`, '1', 'EX', ttl);
}

The key detail is that jti, the JWT ID, must be included when the token is issued. Set the blacklist TTL to the token's remaining lifetime so entries disappear automatically after expiration and storage does not grow without bound. The trade-off is that the JWT changes from fully stateless to partially stateful, adding one Redis query to each request. At roughly 1 ms, that is usually acceptable.

3. Use token versioning. Store a tokenVersion for every user and include it in the JWT payload when signing. Increment tokenVersion after a password change or forced logout. After verification, compare the payload version with the stored version and reject mismatches.

js
// Issue
const jwt = sign({ userId, v: user.tokenVersion });

// Verify
const payload = verify(jwt);
const user = await db.user.findById(payload.userId);
if (payload.v !== user.tokenVersion) return res.status(401).end();

The advantage of versioning is that changing a password can revoke the user's tokens on every device at once. Incrementing the version invalidates all older tokens without maintaining a blacklist. The drawback is that every verification must query the user table, which breaks statelessness just like a blacklist. The approaches complement each other: versioning is suited to revoking every token for a user, while a blacklist revokes one specific token.

Revocation Endpoint

OAuth 2.0 defines a standard revocation endpoint in RFC 7009: POST /oauth/revoke. A client sends a refresh token or access token to the endpoint, and the authorization server invalidates it. NextAuth, Auth0, and Keycloak all implement this endpoint. During sign-out, the backend must also call /oauth/revoke to invalidate the IdP-side refresh token. Otherwise, your local session is gone while the IdP refresh token remains active. The next OIDC login can then happen silently without interaction, which means the user was not fully signed out.

7. In Practice: PKCE Configuration for NextAuth / Auth.js

The previous sections covered the principles. This section applies them in code. NextAuth v5, also known as Auth.js, is the de facto standard in the Next.js ecosystem in 2026. It enables PKCE by default, but many developers do not know how to verify or tune it, or where to investigate failures. The following is a production-grade configuration.

Core Configuration: Enforce PKCE and Secure Cookies

ts
// auth.ts — production-grade NextAuth v5 (Auth.js) configuration
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import type { Provider } from 'next-auth/providers';

const providers: Provider[] = [
  GitHub({
    clientId: process.env.AUTH_GITHUB_ID,
    clientSecret: process.env.AUTH_GITHUB_SECRET,
    // NextAuth uses authorization_code + PKCE by default for every OAuth provider
    // To verify explicitly, check that the redirect URL contains code_challenge + code_challenge_method=S256
  }),
  Google({
    clientId: process.env.AUTH_GOOGLE_ID,
    clientSecret: process.env.AUTH_GOOGLE_SECRET,
    authorization: {
      params: {
        prompt: 'consent',         // Show consent every time to obtain a new refresh_token
        access_type: 'offline',    // Google requires offline access to issue a refresh_token
        scope: 'openid email profile',
      },
    },
  }),
];

export const { handlers, auth, signIn, signOut } = NextAuth({
  trustHost: true,                 // Required in production, or redirect_uri may be constructed incorrectly
  secret: process.env.AUTH_SECRET, // Encrypts cookies/JWTs; use a random value of at least 32 bytes
  providers,
  session: { strategy: 'jwt' },    // Serverless-friendly; use 'database' when a database is available
  cookies: {
    // Server Components can read the session directly without an additional provider
    // httpOnly + Secure + SameSite=Lax are defaults; specify them explicitly for auditing
    sessionToken: {
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },
  callbacks: {
    // Store the refresh token in the JWT so the callback can renew it later
    async jwt({ token, account, user }) {
      // First login: account contains the tokens returned by OAuth
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.expiresAt = account.expires_at * 1000; // ms
        token.userId = user.id;
      }
      // Not expired: return immediately
      if (Date.now() < token.expiresAt - 60_000) {
        return token;
      }
      // Expired: renew using the refresh token
      if (token.refreshToken) {
        return refreshAccessToken(token);
      }
      return { ...token, error: 'RefreshTokenExpired' };
    },
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.error = token.error;
      return session;
    },
  },
});

Access Token Renewal in Auth.js

NextAuth does not automatically use refresh tokens to renew access tokens. You must implement that behavior in the jwt callback. Continuing from the configuration above:

ts
// auth.ts — refresh access token
async function refreshAccessToken(token) {
  try {
    // Google's token endpoint; each provider differs, so consider extracting a lookup table
    const res = await fetch('https://oauth2.googleapis.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: process.env.AUTH_GOOGLE_ID,
        client_secret: process.env.AUTH_GOOGLE_SECRET,
        grant_type: 'refresh_token',
        refresh_token: token.refreshToken as string,
      }),
    });
    const refreshed = await res.json();
    if (!res.ok) throw refreshed;

    return {
      ...token,
      accessToken: refreshed.access_token,
      expiresAt: Date.now() + refreshed.expires_in * 1000,
      // Some providers issue a new refresh_token on every refresh; others do not
      // Use the new one when present, or retain the old one otherwise; Google usually retains it
      refreshToken: refreshed.refresh_token ?? token.refreshToken,
    };
  } catch (e) {
    console.error('refresh failed', e);
    return { ...token, error: 'RefreshTokenExpired' };
  }
}

Several easily overlooked details cause most bugs:

  • AUTH_SECRET must be strong. It encrypts the JWT stored in your cookie, which contains the access and refresh tokens. A weak value leaves those tokens exposed. Generate one with openssl rand -base64 32.
  • trustHost: true is required in production. Without it, NextAuth constructs redirect_uri using NEXTAUTH_URL, which is often wrong behind Vercel or a reverse proxy and causes redirect_uri mismatch errors.
  • PKCE is enabled by default, but test it. After starting the login flow, use F12 to inspect the redirect request in the Network panel. Its query must contain code_challenge and code_challenge_method=S256. If not, the provider configuration or version has a problem.
  • NextAuth handles state and nonce automatically. Do not modify them yourself; unnecessary changes can introduce CSRF vulnerabilities.
  • Sign out completely. signOut() clears your own session by default, but the IdP-side refresh token remains active. For complete sign-out required by GDPR or high-security environments, call the provider's revocation endpoint or use OIDC RP-Initiated Logout.

Using It on the Frontend

tsx
// app/dashboard/page.tsx
import { auth, signIn, signOut } from '@/auth';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    return (
      <form action={signIn.bind(null, 'github')}>
        <button type="submit">Sign in with GitHub</button>
      </form>
    );
  }

  // session.error === 'RefreshTokenExpired' → refresh failed; prompt the user to sign in again
  if (session.error === 'RefreshTokenExpired') {
    return (
      <form action={signIn.bind(null, 'github')}>
        <p>Your session has expired. Please sign in again.</p>
        <button type="submit">Sign in again</button>
      </form>
    );
  }

  return (
    <div>
      <p>Welcome. The access token remains in memory and requires no manual management.</p>
      <form action={signOut}><button>Sign out</button></form>
    </div>
  );
}

Using signIn and signOut directly in Server Actions is the approach recommended by NextAuth. Tokens remain on the server throughout the process, and frontend JavaScript cannot access the refresh token. This is exactly the security model described earlier. If your SPA uses a frontend-only framework rather than Next.js, you need an /api/auth/refresh backend proxy. Keep the refresh token in an httpOnly cookie and expose only the short-lived access token to the frontend.

Summary

  • PKCE uses a one-time dynamic handshake between code_verifier and code_challenge to replace the client_secret that public clients cannot hold, defeating authorization code interception attacks. Always use S256; plain is historical baggage.
  • The implicit flow must die: it exposes tokens in URL fragments, provides no refresh token, and cannot authenticate the client. OAuth 2.1 and the Security BCP have removed it from the specification. Authorization Code Flow + PKCE is the only recommended approach for SPAs and mobile apps in 2026.
  • The two tokens divide responsibilities: an access token is short-lived, usually 15 to 30 minutes, and used frequently; a refresh token is long-lived, usually 7 to 30 days, and crosses the network only during renewal. Refresh tokens must live in httpOnly cookies and never in localStorage.
  • Refresh Token Rotation + reuse detection is the recommended default for public clients: every refresh issues a new refresh token and consumes the old one once. If the old token is submitted again, revoke the entire chain immediately. This upgrades passive exposure-window reduction into active theft detection.
  • For revocation, accept that access tokens are difficult to invalidate immediately and reduce the window with short lifetimes. Use a blacklist or token versioning when strong revocation is required. During sign-out, remember to call the provider's revocation endpoint; otherwise, the IdP-side refresh token remains active and the next silent login means the user was never fully signed out.

In one sentence: OAuth security is not about which flow you use, but about putting every token in the right place, pairing every public channel with a one-time proof, and applying rotation and reuse detection to every refresh. The devil is in the details, and those details are never written in the README.

Related

Related Content