Knowledge Base

JWT Implementation: Refresh Tokens vs. Private Key

guide backend20 min read

Share

An in-depth guide on implementing JSON Web Tokens (JWT) using refresh tokens and private keys.

Part 1: JWT Implementation: Refresh Tokens vs. Private Key

This guide provides a comprehensive breakdown of how refresh tokens and access tokens work within a JWT authentication flow. It explains why a refresh token should never be treated as a “private key,” and how to architect signing and verification securely.

A refresh token is not a substitute for your cryptographic signing key. Instead, the signing key (HMAC secret or RSA/EC private key) must remain strictly on the server, while the refresh token serves as a long‐lived credential that is stored and validated server‐side.


1. JWT Signing & Verification: The Real “Keys”

Signing Key (Secret or Private Key)

  • This key lives only on your server (or in a secured KMS/HSM).
  • You use it to sign every access‐token JWT you issue.
  • It must never be shared with clients. If it leaks, anyone can forge valid JWTs.

Verification Key (Same Secret or a Public Key)

  • If you use HMAC (e.g. HS256), then signing and verification use the same secret. Clients never see that secret; your server just calls jwt.verify(...).
  • If you use asymmetric signing (e.g. RS256/ES256), then you sign with your private key and verify with the public key.
    • You can distribute the public key to downstream services (or embed it in your resource server) so they can independently check the JWT’s signature.
    • But the private key stays on your auth server and is never exposed to clients.

Key point: The JWT signing key (whether a shared HMAC secret or an RSA/EC private key) is the true >“private key” in the system. Clients only ever get a signed token (the access JWT), not the signing key itself.

2. Access Token vs. Refresh Token: What Each Really Is

Access Token (JWT)

  • A short‐lived, self‐contained credential.
  • Contains claims (e.g. user ID, roles, expiration) signed by your server.
  • Bearer‐style: whoever holds it can “act as” that user for the duration of its lifetime (for example, 10–15 minutes).
  • Sent on every API request in the Authorization: Bearer <access_token> header.
  • Resource servers verify its signature (with the HMAC secret or with your public key) and check the exp claim.
  • Once it expires, clients must use a valid refresh token to get a new access token.

Refresh Token

  • A long‐lived credential whose sole purpose is to obtain new access tokens.
  • It is typically:
    1. A random, high‐entropy opaque string (e.g. a UUID or a securely generated random byte sequence), or
    2. A JWT with a very long expiration (less common—because if a long‐lived JWT leaks, it’s riskier).

Key point: The refresh token is not a signing key. It is just a bearer credential that your server checks against a database (or an in-memory store, or Redis) to see “Is this refresh token still valid/issued?”

  • Stored by the client in an HTTP-only, secure cookie (or in secure storage if you’re building an SPA with strict XSS protections). The client never “sees” the raw refresh‐token string if you use cookies with the HttpOnly flag.

3. Why You Should Not Treat a Refresh Token as a “Private Key”

Private‐Key Role: A private key in a JWT setup is what actually signs the access token. It must remain secret, and is used mathematically (RSA/EC) or as an HMAC secret.

Refresh Token Role: A refresh token is just a long‐lived opaque value (or a long‐lived JWT). It’s stored server-side (e.g. in a database table or Redis) so that when the client presents it, you can look up its status (still valid? user not disabled? device not blocked?).

If you were to give a client something that it could use to sign future access tokens, they could mint any access token they want. That completely breaks security.

In other words: If you hand out your “refresh token” as if it were your signing private key, the client could just create a fresh, valid access‐token JWT at will—bypassing every check. That is why your signing key can never leave your backend environment.

4. A Proper JWT Flow in Node.js (HS256 or RS256)

The following is a canonical way to implement JWT + refresh token logic in Node.js/Express. The exact library calls will depend on whichever JWT library you choose (e.g. jsonwebtoken, jose, @auth0/node-jsonwebtoken, etc.), but the pattern remains:

4.1 At Login (Issue Both Tokens)

  1. Authenticate User Credentials (e.g. email/password)
  2. Create an Access Token (JWT)
    Payload might include:
    {
      sub: user.id,
      name: user.name,
      roles: user.roles,
      iat: <now>,
      exp: <now + 15 minutes>
    }
    

    Sign it with:
    • For HS256: a server‐only secret (e.g. process.env.JWT_SECRET).
    • For RS256: your RSA/EC private key (loaded via fs.readFileSync + crypto.createPrivateKey(...) or a JWK).
    import jwt from 'jsonwebtoken';
    // HS256 example:
    const accessToken = jwt.sign(
      { sub: user.id, name: user.name, roles: user.roles },
      process.env.JWT_SECRET,               // keep this secret on server only
      { algorithm: 'HS256', expiresIn: '15m' }
    );
    
  3. Create a Refresh Token
    Best practice: generate a totally random string, e.g.:
    import { randomBytes } from 'crypto';
    const refreshToken = randomBytes(32).toString('hex'); // 256-bit of randomness
    

    Store refreshToken in your database or Redis, associated with user.id and a rotation/expiry timestamp (e.g. expiresAt = now + 30 days).
    Optionally hash the refresh token in the database (so your DB doesn’t store the raw token if it ever leaks). E.g. hashed = sha256(refreshToken) and store hashed + userId.
  4. Send Both Tokens to the Client
    • Access Token: Usually returned in the JSON response body.
    • Refresh Token: Send as an HTTP-only, Secure cookie:
    // Example Express route handler
    res
      .cookie('refreshToken', refreshToken, {
        httpOnly: true,
        secure: true,       // in production, only over HTTPS
        path: '/auth/refresh',
        maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
      })
      .json({ accessToken });
    

🔒 Why not return the refresh token in JSON?

If you return it in JSON, front-end code could read it (localStorage), making it vulnerable to XSS.

By using an HTTP-only cookie, you force the browser to send it automatically, but JS can’t inspect it.


4.2 On Every API Call (Protecting Routes)

Client Sends Access Token in Header

GET /api/protected
Authorization: Bearer <accessToken>

Server Middleware Verifies Access Token

  • With HS256:
    try {
      const payload = jwt.verify(token, process.env.JWT_SECRET);
      req.user = { id: payload.sub, roles: payload.roles };
      next();
    } catch (err) {
      // token expired or invalid → return 401
      res.sendStatus(401);
    }
    
  • With RS256:
    import fs from 'fs';
    const publicKey = fs.readFileSync('/path/to/public.pem');
    const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    
    // If valid → Proceed → req.user holds the user’s identity
    

If Invalid/Expired → Return 401 Unauthorized

Key point: Access tokens are verified using your server‐only HMAC secret or your server’s public key (if you used RS256). The client never sees the HMAC secret or RSA private key.


4.3 When Access Token Expires (Using the Refresh Token)

  1. Client Detects 401 from Protected Route (Expired Access Token)
  2. Front-end code makes a request to /auth/refresh (e.g. POST /auth/refresh) with no body.
    Because you set refreshToken as an HTTP-only cookie on Path=/auth/refresh, the browser automatically includes that cookie.
  3. Server’s /auth/refresh Handler
    Extract the cookie:
    const refreshToken = req.cookies.refreshToken;
    

    Look up refreshToken (or its hash) in your database/Redis:
    // 2a. Assume you stored `hashedToken = sha256(refreshToken)` when you first issued it.
    const hashed = sha256(refreshTokenFromCookie);
    const record = await db.findRefreshToken(hashed);
    if (!record) throw new Error('Invalid refresh token');
    
    // 2b. If valid, generate a new access token:
    const newAccessToken = jwt.sign(
      { sub: record.userId, roles: record.userRoles },
      process.env.JWT_SECRET,
      { algorithm: 'HS256', expiresIn: '15m' }
    );
    
    // 2c. (Optional) implement “rotate on use”:
    //      i. Remove the old refresh token record from DB.
    //     ii. Generate a new random refreshToken (randomBytes(32).toString('hex'))
    //    iii. Store new hashedRefreshToken in DB with new expiry.
    //     iv. Set new HTTP-only cookie: res.cookie('refreshToken', newRefreshToken, …)
    
    return res.json({ accessToken: newAccessToken });
    
  4. Client Retries Original API Call with New Access Token.

🔒 Security note:

If an attacker somehow steals a refresh token, they can keep minting new access tokens until that refresh token is revoked/expired.

That’s why you want to keep refresh tokens in an HttpOnly, Secure cookie, check IP/client fingerprints if needed, and rotate them on every use (so you can blacklist a stolen one).

5. Summary: Why “Refresh Token as Private Key” Is Incorrect

  • You never hand out your signing key—that is your “private key.” It stays on the server (or KMS).
  • A refresh token is just a long‐lived credential you validate against a store. It is not used to sign anything.
  • Access tokens (JWTs) already contain the client-visible “proof” (the signed payload). Resource servers verify that proof with your HMAC secret (HS256) or with your public key (RS256).
  • Treat the refresh token like a password or API key—only your back end can verify it (by looking it up in a database or by decrypting/verifying a signed JWT). Don’t let the client “sign” tokens or think of the refresh token as a signing key.

6. If You Really Want Asymmetric JWT (RS256) Across Services

Sometimes you issue an access token on Service A and want Service B (a separate microservice) to trust it without hitting a central database. In that scenario:

  1. Generate an RSA/EC key pair on your auth server (e.g. with openssl or crypto.generateKeyPairSync('rsa', …)).
  2. Keep the private key on your auth server only; all JWTs are signed with that.
  3. Publish the public key (or JWK Set) to any downstream service that needs to verify access tokens—e.g. via a well‐known endpoint like https://auth.myapp.com/.well-known/jwks.json.
  4. Downstream services load that public key (or periodically rotate and cache it), then call jwt.verify(token, publicKey, { algorithms: ['RS256'] }).

Refresh token logic remains exactly the same: an opaque, server‐validated credential that never leaves your auth cluster.

Auth Server:

  • Holds private.pem (never exposed).
  • Signs JWT access tokens:
    const accessToken = jwt.sign(
      { sub: user.id, … },
      fs.readFileSync('./private.pem'), // private key
      { algorithm: 'RS256', expiresIn: '15m' }
    );
    
  • Creates/stores refresh tokens in DB.
  • Exposes /auth/refresh that verifies refresh tokens and issues new JWTs.

API Gateway / Microservice:

  • Holds public.pem (only the public half).
  • Verifies incoming JWTs:
    const payload = jwt.verify(token, fs.readFileSync('./public.pem'), { algorithms: ['RS256'] });
    
  • Never sees the private key, never touches refresh tokens.

Again: neither the access token nor the refresh token “is” the private key. The private key is solely used to sign the access token; the access token itself is just data + signature. The refresh token is just a server‐validated string (or long‐lived JWT) whose only job is to let you issue a new access token.

7. Putting It Into Code: Node.js Example (HS256)

The following is a minimal Express snippet showing:

  • How to issue JWTs (HS256).
  • How to store/validate refresh tokens in an in‐memory map (for demo only—use a real DB in production).
  • How to rotate refresh tokens on use.

IMPORTANT: Never use an in‐memory store for refresh tokens in production. Use a persistent store (Redis, SQL, etc.), so you can revoke tokens if your server restarts or if you need to blacklist a compromised token.

// File: authServer.js
import express from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';
import { randomBytes, createHash } from 'crypto';

const app = express();
app.use(express.json());
app.use(cookieParser());

const JWT_SECRET = process.env.JWT_SECRET || 'super-secure-secret-key'; // keep this safe
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY_SEC = 30 * 24 * 60 * 60; // 30 days in seconds

// Simulated in‐memory store for refresh tokens:
// key = hashedRefreshToken, value = { userId, expiresAt: unixTimestampSec }
const refreshTokenStore = new Map();

// Utility to hash a token before storing in DB:
function hashToken(token) {
  return createHash('sha256').update(token).digest('hex');
}

// 1. LOGIN: Validate credentials, then issue both tokens
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  // (In production) verify email+password from your user database:
  const user = await findUserByEmail(email);
  if (!user || !checkPassword(user, password)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // 1a. Issue access token (short‐lived)
  const accessToken = jwt.sign(
    { sub: user.id, name: user.name, roles: user.roles },
    JWT_SECRET,
    { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  // 1b. Issue a refresh token (random string), store its hash in DB
  const rawRefreshToken = randomBytes(32).toString('hex'); // 256-bit
  const hashed = hashToken(rawRefreshToken);
  const expiresAt = Math.floor(Date.now() / 1000) + REFRESH_TOKEN_EXPIRY_SEC;

  refreshTokenStore.set(hashed, { userId: user.id, expiresAt });

  // 1c. Send both to the client (access in JSON; refresh as HttpOnly cookie)
  res
    .cookie('refreshToken', rawRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      path: '/auth/refresh',
      maxAge: REFRESH_TOKEN_EXPIRY_SEC * 1000
    })
    .json({ accessToken });
});

// 2. PROTECTED ROUTE: Verify Access Token
app.get('/api/protected', (req, res) => {
  const authHeader = req.headers['authorization'] || '';
  const token = authHeader.split(' ')[1]; // “Bearer <token>”
  if (!token) return res.sendStatus(401);

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    // payload = { sub: user.id, name: user.name, roles: [ … ], iat: <n>, exp: <n> }
    req.user = { id: payload.sub, roles: payload.roles };
    return res.json({ data: 'This is protected data', user: req.user });
  } catch (err) {
    return res.sendStatus(401);
  }
});

// 3. REFRESH: Validate refresh token, rotate on use, issue new access token
app.post('/auth/refresh', (req, res) => {
  const rawRefreshToken = req.cookies.refreshToken;
  if (!rawRefreshToken) {
    return res.sendStatus(401);
  }

  const hashed = hashToken(rawRefreshToken);
  const record = refreshTokenStore.get(hashed);

  if (!record) {
    return res.sendStatus(401); // no such token → unauthorized
  }

  const nowSec = Math.floor(Date.now() / 1000);
  if (record.expiresAt < nowSec) {
    // expired refresh token
    refreshTokenStore.delete(hashed);
    return res.sendStatus(401);
  }

  // 3a. Rotate the refresh token:
  refreshTokenStore.delete(hashed); // revoke old
  const newRawRefresh = randomBytes(32).toString('hex');
  const newHashed = hashToken(newRawRefresh);
  const newExpiresAt = nowSec + REFRESH_TOKEN_EXPIRY_SEC;
  refreshTokenStore.set(newHashed, { userId: record.userId, expiresAt: newExpiresAt });

  // 3b. Issue a new access token:
  const newAccessToken = jwt.sign(
    { sub: record.userId /*, other claims… */ },
    JWT_SECRET,
    { algorithm: 'HS256', expiresIn: ACCESS_TOKEN_EXPIRY }
  );

  // 3c. Set new refresh token cookie, send new access token in JSON
  res
    .cookie('refreshToken', newRawRefresh, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      path: '/auth/refresh',
      maxAge: REFRESH_TOKEN_EXPIRY_SEC * 1000
    })
    .json({ accessToken: newAccessToken });
});

// 4. LOGOUT: Invalidate current refresh token
app.post('/auth/logout', (req, res) => {
  const rawRefreshToken = req.cookies.refreshToken;
  if (rawRefreshToken) {
    const hashed = hashToken(rawRefreshToken);
    refreshTokenStore.delete(hashed);
  }
  // Clear cookie on client
  res
    .clearCookie('refreshToken', { path: '/auth/refresh' })
    .sendStatus(204);
});

app.listen(4000, () => {
  console.log('Auth server listening on port 4000');
});

/**
 * Dummy user‐lookup/password‐check functions for illustration only:
 */
async function findUserByEmail(email) {
  // In reality, query your users table
  if (email === '[email protected]') {
    return { id: 'user123', name: 'Alice', roles: ['user'], passwordHash: '…' };
  }
  return null;
}
function checkPassword(user, password) {
  // In reality, bcrypt.compare(password, user.passwordHash)
  return password === 'correct‐horse‐battery‐staple';
}

### 8. Key Takeaways

- Never confuse the refresh token with your signing key.
- The signing key (HMAC secret or RSA/EC private key) stays on your server, never goes to a client, and is used purely to sign (and verify) JWTs.
- A refresh token is just a long‐lived random credential that your server stores and checks; it has nothing to do with cryptographic signing of access tokens.
- Access tokens are publicly visible to the client, but they are only valid because your server signed them with its private key/secret. The client does not hold any secret—only the signed JWT itself.
- Refresh tokens are also “bearer” tokens, but they are never meant to be parsed or “verified” by the client. They simply live in the client’s HttpOnly cookie, and your server treats them as a key in a serverside store (database/Redis) that says, “Yes, that client is allowed to get a new access token now.”
- If you need asymmetric validation (e.g., microservices), use an RSA/EC key pair: sign with the private key on Auth Server; distribute only the public key to the resource servers to let them verify incoming JWTs. The refresh token stays opaque and is validated only by the Auth Server.
- Do not send any “private key” to the browser. You do not want client code (or malicious third‐parties) to ever have the ability to sign their own tokens.

By following the above pattern, you ensure that:
- No sensitive signing material ever leaves your backend.
- Clients only hold a short‐lived access token and an opaque refresh token in an HttpOnly cookie.
- Your refresh token never “becomes” a private key—it simply lets you decide when to issue a brand‐new signed access token.

**TL;DR**

In a proper JWT flow, you must keep a single signing key (HMAC secret or RSA/EC private key) strictly on the server. You sign access tokens with that key and verify them with its counterpart (the same secret in HS256 or the public key in RS256). The refresh token is merely a long‐lived credential you store server‐side and never share except in an HttpOnly cookie. It should never act as a "private key" and is not used to cryptographically sign or verify access tokens.

---

## Part 2: Stateless Refresh-Token Flow

This section explains how and why you might build a truly stateless refresh‐token flow—along with the inherent trade‐offs. In short, avoiding server‐side state means giving up per‐token revocation and forced expiry. However, if those limitations are acceptable, a self‐contained (JWT) refresh token can live entirely in the client and be validated solely via signature and expiration time.

### 1. What “Stateless Refresh Token” Actually Means

#### Stateless (no server‐side store at all)

* The server keeps zero records of which refresh tokens were issued.
* Each refresh token must be fully “self‐contained”: it carries in its own payload everything the server needs to decide “OK, is this valid?
In practice, that means the refresh token itself is a signed JWT (or JWS) that:

* Encodes a unique user/session identifier (e.g. a user ID or “session ID”).
* Carries an `exp` (expiration) claim.
* (Optionally) carries any other claims you need (e.g. user roles, token version, etc.).

When the client presents that refresh‐JWT, the server just:

1. Verifies its signature against a server‐only signing key.
2. Checks `exp` (and maybe `nbf`).
3. If valid, issues a new access token (and optionally a new refresh token).

No database check—no “lookup” is done. Everything is in the JWT.

Because there’s no lookup (no hash‐table or DB), the server cannot:

* Immediately revoke a single user’s existing refresh token (unless you switch to a new signing key or embed some revocation‐list version, which reintroduces state).
* Force‐expire one client’s refresh token early; once it’s signed and unexpired, it’s valid until its JWT `exp`.
* Detect “replay” of an old token (a stolen refresh JWT can be used until it expires).

#### Why consider stateless at all?

* You avoid any server‐side overhead for storing/reading refresh‐token records (e.g. no Redis or no SQL table).
* Under very high load or simple microservices, you may want to keep your auth servers purely “sign/verify” without hitting a database on every refresh.
* Fewer moving parts can mean easier horizontal scaling: any instance with the same signing key can handle a refresh request in one step.

### 2. How to Build a Self-Contained (Stateless) Refresh Token in Node.js

The following is a minimal outline of a refresh flow that maintains zero state on the server. It uses JWTs (signed with HS256 or RS256), but any JWS-compatible algorithm can be substituted.

#### 2.1 Decide on Your Signing Key

**HS256 (HMAC):**

```js
// Example: put this in your environment (never check into Git)
process.env.JWT_REFRESH_SECRET = 'a‐very‐long‐random‐string‐only‐on‐server';

All instances of your auth service use that same secret to sign/verify.

RS256 (RSA):

# Generate once (do not share the private key)
openssl genrsa -out refresh‐private.pem 2048
openssl rsa ‐in refresh‐private.pem ‐pubout ‐out refresh‐public.pem

Your server loads refresh‐private.pem in memory (via fs.readFileSync + crypto.createPrivateKey(...)) and uses it to sign every refresh JWT.

On verify, you use refresh‐public.pem.

2.2 Define the Refresh-Token Payload

At a minimum, your refresh JWT should contain:

{
  "sub": "<userId>",            // subject = the user’s unique ID
  "typ": "refresh",             // optional: mark it as a refresh token
  "iat": <issued-at>,             // auto‐included by most libraries 
  "exp": <expiry timestamp>,      // e.g. now + 30 days
  // (optionally) "jti": "<some‐unique‐identifier>", if you want to blacklist entire “batches” by rotating keys
}
  • sub: your user’s unique identifier.
  • exp: e.g. now + 30 days if you want a month‐long refresh window.
  • jti: JWT ID (optional). If you ever need to revoke “all old tokens,” you can add a rotated key or a “current‐token‐version” in the user’s profile (but that reintroduces some state).

2.3 Issue a Refresh JWT at Login

import jwt from 'jsonwebtoken';

// after user has successfully authenticated:
function issueTokens(userId) {
  // 1) Access Token (short‐lived)
  const accessToken = jwt.sign(
    { sub: userId, typ: 'access' },
    process.env.JWT_ACCESS_SECRET,   // e.g. HS256 secret or RS256 private key
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  // 2) Refresh Token (long‐lived, self‐contained)
  const refreshToken = jwt.sign(
    { sub: userId, typ: 'refresh' },
    process.env.JWT_REFRESH_SECRET,  // only the auth server knows this
    { algorithm: 'HS256', expiresIn: '30d' }
  );

  return { accessToken, refreshToken };
}

Send the accessToken in JSON response.

Send the refreshToken in an HTTP‐only cookie (or let the client store it in secure storage if you trust your front‐end environment).

Example Express handler:

app.post('/auth/login', async (req, res) => {
  // 1. validate credentials (e.g. DB lookup, bcrypt.compare, etc.)
  // assume user.id == 'alice123'
  const { accessToken, refreshToken } = issueTokens('alice123');

  res
    .cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      path: '/auth/refresh',
      maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
    })
    .json({ accessToken });
});

2.4 Verify the Stateless Refresh JWT (No DB Hit)

When the client calls /auth/refresh, you simply:

  1. Pull refreshToken from the cookie (or from Authorization: Bearer, if you choose).
  2. Verify its signature and expiry with your single shared secret (or RSA public key).
  3. If valid → issue a new access token (and optionally a brand‐new refresh JWT with a fresh exp).
  4. If invalid (expired, malformed, wrong signature) → force a full login.
app.post('/auth/refresh', (req, res) => {
  const rawRefresh = req.cookies.refreshToken;
  if (!rawRefresh) {
    return res.sendStatus(401);
  }

  try {
    // 1) verify signature + expiry
    const payload = jwt.verify(
      rawRefresh,
      process.env.JWT_REFRESH_SECRET, // or the public key if RS256
      { algorithms: ['HS256'] }
    );
    // 2) ensure it’s actually a “refresh” token
    if (payload.typ !== 'refresh') {
      throw new Error('Not a refresh token');
    }

    // 3) payload.sub is the user ID
    const newAccessToken = jwt.sign(
      { sub: payload.sub, typ: 'access' },
      process.env.JWT_ACCESS_SECRET,
      { algorithm: 'HS256', expiresIn: '15m' }
    );

    // 4) (OPTIONAL) If you want to rotate your refresh token’s expiry:
    const newRefreshToken = jwt.sign(
      { sub: payload.sub, typ: 'refresh' },
      process.env.JWT_REFRESH_SECRET,
      { algorithm: 'HS256', expiresIn: '30d' }
    );
    // send the new refresh JWT in the cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      path: '/auth/refresh',
      maxAge: 30 * 24 * 60 * 60 * 1000
    });

    // 5) send new accessToken in JSON
    return res.json({ accessToken: newAccessToken });
  } catch (err) {
    // invalid signature or expired → force re‐login
    return res.sendStatus(401);
  }
});
  • No database or in‐memory lookup is ever performed.
  • The jwt.verify(...) call alone both checks the signature and confirms exp hasn’t passed.
  • You could optionally rotate the refresh JWT on each use (as shown), but that still remains stateless—every old refresh token (signed earlier) is still valid until its original exp passes.

3. Trade-Offs & Security Implications

3.1 You Lose Per-Token Revocation

If you issue a refresh‐JWT that’s valid for 30 days, and the user’s device is stolen on day 5, you cannot immediately revoke it.

The only way to “revoke” would be to rotate your entire JWT_REFRESH_SECRET (or RSA private key), which instantly invalidates all outstanding refresh tokens for all users. That’s almost never acceptable in a large system.

In other words, stateless refresh tokens force you to accept that once signed, each token lives until its built-in expiry (unless you rotate the global signing key).

3.2 You Cannot Track or Invalidate Individual Sessions

You have no way (server‐side) to see which device/session is using which refresh token.

You cannot “log out” a single device without “log out everyone” (key rotation) or embedding some “revokedTokensList” reference that reintroduces state.

3.3 Replay Risk Is Permanent Until Expiration

If someone exfiltrates a valid refresh JWT, they can keep calling /auth/refresh until that JWT’s exp timestamp passes.

You have no way to detect that replay happens because you never store “this token was already used.”

In a more stateful design, you might store a jti (JWT ID) in a DB and flag it as used or revoked; stateless means you don’t.

3.4 Clock-Skew & Expiry Boundaries

You must ensure all your servers’ clocks are tightly synchronized (e.g. via NTP).

If a client’s clock is off by a few minutes, it may try to refresh a token that your server already considers expired (or vice versa).

4. When Is Stateless Refresh Reasonable?

  • Small/Short-Lived Deployments or Internal Tools
    If you don’t care about revocation or compromised refresh tokens—e.g. internal tools that rotate keys every day.
  • Low-Risk Data
    If the access tokens only grant access to non-critical resources (e.g. a public RSS feed).
  • Very Low Friction Environments
    If you can tolerate “force-expire everyone if the key leaks.”
  • Microservices That Rely on Downstream Validation Only
    If your gateways or downstream services need to verify refresh tokens without hitting a central DB, you save that hop—but accept the lack of revocation.

5. Alternatives That Keep Some “State” But Minimize It

If you want most of the scaling benefits of statelessness but still occasionally need to revoke or blacklist:

5.1 Store a “Token Version” or “Token Counter” in the User’s Record

  • In each refresh‐JWT’s payload, embed a custom claim, e.g. ver: 3.
  • In your user table, store currentRefreshVersion = 3.
  • On /auth/refresh, do:
    const payload = jwt.verify(...);
    if (payload.ver !== user.currentRefreshVersion) throw 401;
    // otherwise, issue new tokens with ver++
    update user.currentRefreshVersion in DB;
    
    • Pros: You only store a single integer per user. You can revoke all refresh tokens for user “alice” by bumping currentRefreshVersion.
    • Cons: You still need exactly one DB lookup per refresh (to check payload.ver against user.currentRefreshVersion). It’s not completely stateless, but it’s minimal state.

5.2 Shorten the Refresh Token Expiry

If you’re worried about stolen refresh tokens, reduce the TTL from 30 days to say 7 days or even 24 hours.

  • The shorter its window, the less time an attacker has to abuse stolen credentials.
  • But now your clients must refresh more frequently.

5.3 Use a “Sliding Window” in a Very Thin Cache

  • Keep a small in‐memory LRU cache (or Redis) of recently issued jti values.
  • Whenever a refresh‐JWT is used, you check “have I seen this jti already? If yes, possibly replay attack. If no, mark it seen for next 10 minutes.”
  • This only catches immediate replay, not longer‐term theft. Also, it’s still a stateful store, just extremely short‐lived.

6. Code Example: Stateless vs. Minimal State

The following is a side-by-side sketch:

6.1 Fully Stateless (No DB at All)

// REFRESH_SECRET is an HS256 secret only on server
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;

// Issue at login:
function issueStatelessRefreshToken(userId) {
  // no “jti” or DB check—just a long‐lived exp
  return jwt.sign(
    { sub: userId, typ: 'refresh' },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '30d' }
  );
}

// /auth/refresh route:
app.post('/auth/refresh', (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.sendStatus(401);

  try {
    const payload = jwt.verify(token, REFRESH_SECRET, { algorithms: ['HS256'] });
    if (payload.typ !== 'refresh') throw new Error();
    // OK: issue new access & (optionally) new refresh
    const accessToken = jwt.sign({ sub: payload.sub, typ: 'access' }, ACCESS_SECRET, { expiresIn: '15m' });
    // If rotating, generate a brand‐new refresh here:
    const newRefresh = jwt.sign({ sub: payload.sub, typ: 'refresh' }, REFRESH_SECRET, { expiresIn: '30d' });
    res.cookie('refreshToken', newRefresh, { httpOnly: true, secure: true, path: '/auth/refresh', maxAge: 30*24*60*60*1000 });
    return res.json({ accessToken });
  } catch (e) {
    return res.sendStatus(401);
  }
});
  • No DB calls anywhere.
  • Once a refresh JWT is issued, it lives until its own exp passes (or until you rotate REFRESH_SECRET globally).

6.2 Minimal State: Single “Token Version” Field

// Assume a user table schema: { id, email, passwordHash, refreshVersion }
import User from './models/User.js'; // (ORM or raw query, whatever)

// At login:
async function loginHandler(req, res) {
  const { email, password } = req.body;
  const user = await User.findOne({ where: { email } });
  if (!user || !compareHash(password, user.passwordHash)) {
    return res.sendStatus(401);
  }

  // Issue access
  const accessToken = jwt.sign(
    { sub: user.id, typ: 'access' },
    ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );

  // Issue refresh with “ver” equal to user.refreshVersion
  const refreshToken = jwt.sign(
    { sub: user.id, typ: 'refresh', ver: user.refreshVersion },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '30d' }
  );

  res
    .cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, path: '/auth/refresh', maxAge: 30*24*60*60*1000 })
    .json({ accessToken });
}

// /auth/refresh route:
app.post('/auth/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.sendStatus(401);

  let payload;
  try {
    payload = jwt.verify(token, REFRESH_SECRET, { algorithms: ['HS256'] });
  } catch {
    return res.sendStatus(401);
  }
  if (payload.typ !== 'refresh') return res.sendStatus(401);

  // 1) Look up user row to check version
  const user = await User.findByPk(payload.sub);
  if (!user) return res.sendStatus(401);

  // 2) If token’s ver ≠ user.refreshVersion → it’s been revoked/stale
  if (payload.ver !== user.refreshVersion) {
    return res.sendStatus(401);
  }

  // 3) Everything lines up → issue new tokens, bump version
  const newAccess = jwt.sign(
    { sub: user.id, typ: 'access' },
    ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m' }
  );
  // bump refreshVersion
  await user.update({ refreshVersion: user.refreshVersion + 1 });

  // issue a brand‐new refresh with the new version
  const newRefresh = jwt.sign(
    { sub: user.id, typ: 'refresh', ver: user.refreshVersion },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '30d' }
  );

  res
    .cookie('refreshToken', newRefresh, { httpOnly: true, secure: true, path: '/auth/refresh', maxAge: 30*24*60*60*1000 })
    .json({ accessToken: newAccess });
});
  • One extra read and one extra write (per refresh) to check/increment refreshVersion.
  • You get immediate revocation (by incrementing refreshVersion if you suspect compromise).
  • You still do not store every single token string—just a single integer per user. This is often a good middle ground.

7. When (and Why) to Avoid Fully Stateless Refresh

Regulatory or Security Requirements

  • If regulators require the ability to revoke tokens on demand (e.g. when a user requests “log me out of all devices”), you need some state.
  • If your app handles highly sensitive data (banking, healthcare), you cannot afford to let a stolen refresh JWT live until expiry.

User Experience

  • If you want to allow users to “log out — immediately,” you must change something server‐side. A stateless JWT can’t detect a logout until it expires.
  • If you want to show “Active sessions in other browsers/devices” and let the user revoke them, you need to record each issuance server‐side.

Granular Token Control

  • If you want to revoke a single device’s refresh token (but leave others active), you must track each one separately in a DB or store a per‐token entry somewhere.

8. Summary of Recommendations

  • If you truly want zero state (fully stateless):
    • Issue refresh tokens as self-contained JWTs signed with a server‐only key.
    • Verify them purely by signature+exp.
    • Accept that you cannot revoke them individually—only rotate your global signing key to kill them all.
    • Be prepared for replay risk until each token’s exp runs out.
  • If you need minimal state but still low overhead:
    • Store a single integer (“token version”) or a single timestamp (“issuedAt cut‐off”) per user.
    • Embed that version/timestamp in each refresh JWT (ver or iat) and check it on /auth/refresh.
    • This lets you revoke or “expire all previously issued refresh tokens” for that user in one DB write, but still avoids storing every token string.
  • If you need full revocation or per-device tracking:
    • Store each refresh token’s hash (or jti) in Redis or SQL with an expiry.
    • On each refresh, check/rotate/delete that single token record.
    • This is the most secure, but it requires a stateful lookup on every refresh (and some storage cleanup process).

Bottom Line

Stateless (in-memory-free) means your refresh token is a signed JWT with an exp. The server only checks signature+expiry, never a database. That does achieve full “statelessness,” but you forfeit on-demand revocation or forced logout for individual sessions.

If you can live with “refresh tokens are valid until their built-in expiry, unless you rotate the global signing key,” then a self-contained JWT is fine. Otherwise, introduce some minimal state (e.g. token‐version per user) to regain revocation capability without a full in‐memory table of tokens.

Choose the approach whose security guarantees and operational overhead best match your application’s risk profile.

Part 3: Layered Defense Against Refresh Token Theft

Even with strong standard security measures in place (HttpOnly + Secure + SameSite cookies, one‐time refresh tokens, CSRF tokens on the refresh endpoint, short TTLs, and anomaly detection), there is still a fundamental problem anytime an attacker extracts the raw token from a browser:

Once an attacker has the exact string that was issued to a user, nothing in server‐side “require both token and userId” logic will stop them from replaying it.

This section covers where attackers typically succeed, how standard mitigations help, and what additional defenses can be layered on top to make real‐world compromise significantly harder.

  • If an attacker exfiltrates the refresh‐token cookie, they already know exactly which user it belongs to (their browser holds that token, and it was set under that user’s session). Asking for an extra user‐ID or CSRF value at refresh time won’t block a malicious script that already controls the victim’s browser context.
  • XSS is the most common way cookies get stolen—even with HttpOnly set, there are clever workarounds (e.g. if an attacker can modify the page to submit a hidden form to your refresh endpoint, they can force the user’s browser to send the HttpOnly cookie for them). SameSite=Strict helps mitigate that, but only if the attacker’s payload can’t run from the same top‐level origin.
  • Phishing or “push a malicious browser extension” can simply read the cookie jar or do a fetch('/auth/refresh') on behalf of the user (sending the HttpOnly cookie automatically). Again, SameSite=Strict only stops cross‐site GET/POST if the browser honors it, but it won’t help if malicious code is already running inside your origin.

In short: once a refresh token is exfiltrated at the client, it is effectively “in the wild” and can be replayed—no server‐side check of userId or visitor_id will stop that.

2. Standard Baseline Mitigations

  • HttpOnly + Secure + SameSite=Strict cookies This is the single most important control for protecting tokens from “simple” XSS or cross‐site request forgery.
  • Very short TTL (3–5 days) Limits the window during which a stolen token works.
  • One‐time‐use / revocation flag By marking each token as invalid once it’s used (and storing that in the DB), you prevent “long‐lived, unrevokable tokens.” If your server sees the same refresh token used twice, you know something’s up.
  • CSRF token on the refresh endpoint If an attacker tries to trick the user’s browser into POSTing to /auth/refresh, they will need to know your dynamically generated CSRF token. (Assuming you’ve stored that token in a secure, same‐site cookie or in a hidden form input.)
  • Additional re‐auth checks for very sensitive routes Email confirmation or requiring the old password again for “change password” or “change email” actions means that even if someone hijacks a session, they still can’t immediately alter highly‐sensitive settings without that second factor.

All of these measures collectively shrink the “attack surface.” A would‐be attacker now needs to:

  1. Run code inside your origin (to bypass SameSite/HttpOnly),
  2. Extract the raw refresh cookie,
  3. Observe the CSRF token or trick the page into including it,
  4. Present the token before it expires or is revoked.

That is orders of magnitude harder than simply “guessing a userId,” so you’re on the right track.

3. Where real‐world attackers still succeed—and how to make that harder

The following additional layers can be implemented. While none are silver bullets, each significantly raises the bar:

A. Refresh‐token rotation + “untrusted device” flag

  • Rotate on every use (you’re already invalidating each token once it’s used). That means if an attacker steals a valid token, the moment the real user’s app does a “silent” refresh, it will invalidate the stolen token.
  • In parallel, record fingerprint or “device ID” whenever you issue a token. That could be a hash of:
    • User‐agent string
    • A random GUID your frontend generated (and stored in localStorage)
    • IP address (with care—mobile users often change IPs)
    • Any other non‐spoofable client attribute (e.g. a TLS client certificate or WebAuthn key).
  • When you generate the original refresh token, save device_id = ABC123 alongside it. Then, on subsequent refresh calls, re‐compute that same “device ID” from the request. If it doesn’t match the value stored in the DB, treat this request as suspicious:
    1. Force a full re‐login (throw away the stolen token), or
    2. Send an email “We detected a login from a new device/IP. Was this you?”

This way, even if someone copies the raw token, they cannot present the correct “device ID.” They could try to imitate it, but if you choose a fingerprint that’s hard to spoof (e.g. a random GUID stored in localStorage), you will catch the thief.

B. Anomaly detection & risk‐based throttling

  • Log every refresh attempt (timestamp, IP address, userAgent, device fingerprint).
  • If your system sees “User 42’s refresh token was used by IP=1.2.3.4 at 3 pm, and then by IP=5.6.7.8 five minutes later,” you can immediately revoke all outstanding refresh tokens for that user.
  • You can also temporarily lock down the account or force an MFA challenge if there’s unusual behavior (e.g. thousands of refresh attempts in a short time, or a geo-location jump from USA → Europe within minutes).

C. Offline revocation list + forced re-authentication after inactivity

  • Even though you mark each refresh token invalid on use, you might also:
    • Keep a global userIsLockedOut flag in your users table. If you detect any suspicious refresh behavior, set users.isLocked = true. Then on every refresh or API call, you immediately reject until the user explicitly re-logs in (and perhaps changes their password).
    • Have a short inactivity timer: if the user hasn’t used the app in, say, 24 hours, force a full login—even if their refresh token hasn’t technically expired. This reduces the window for replay.

D. Device / browser isolation with subdomains

  • If you serve your front end on app.example.com but your refresh endpoint runs on auth.example.com, you can limit cookie scope even further.
  • Issue the refresh cookie with Domain=auth.example.com; Path=/; Secure; HttpOnly; SameSite=None (or whatever is appropriate).
  • Your JavaScript on app.example.com never sees that cookie at all. To invoke a refresh, you do a hidden <iframe src="https://auth.example.com/refresh"> or a fetch with credentials: 'include'.

Even if an XSS vulnerability exists on app.example.com, it cannot read or exfiltrate cookies set under auth.example.com. It can only force the browser to send them, which at least denies the attacker the ability to steal the raw token.

E. Enforce MFA / step-up authentication for sensitive actions

  • You mentioned that password changes require the old password and email verification. You can extend this principle:
    • Every N days, force an MFA re-prompt. E.g. once a week, for any action—even a simple refresh—you require a one-time code.
    • For any refresh from a new device or new IP, step up to 2FA.

That way, even if someone has the raw token, they cannot refresh it from an unfamiliar context without that second factor.

F. Content Security Policy (CSP) / XSS hardening

  • Lock down your CSP so that even if an attacker can inject a small <script>, they cannot load external JS or steal cookies easily. Use script-src 'self' (or 'nonce-…') and style-src 'self'.
  • Audit any third-party libs/plugins that might open an XSS vector. A single malicious NPM dependency can bypass HttpOnly by inserting inline scripts that forward cookies as form posts.
  • Use strict input sanitization / encoding to prevent stored XSS in user profiles or comments. The fewer XSS vectors you have, the harder it is for an attacker to exfiltrate cookies.

4. If you absolutely must bind tokens to a specific user ID

Requiring the user to send both userId + token can make automated attacks slightly more cumbersome in theory, but in practice:

  • User ID is often public—it can appear in your JSON API responses, in profile pages, in client-side code, etc. An attacker who already controls a malicious script in the user’s browser can simply read the DOM or make a quick GET /api/me call to learn the numeric ID.
  • If an attacker steals token Y from user 42, they already have Y and already know this was issued for user 42. Trying Y with any other ID will fail. Trying Y with “42” will succeed. There’s no need to protect that numerical “42” value; it’s not a secret.
  • A “JOIN on token_hash” query alone is sufficient to prove “token Y belongs to user 42.” You don’t gain anything meaningful by also requiring “AND user_id = 42” in SQL—because the only time SQL will return a row is when both are true. It only increases your code complexity slightly, without significantly enhancing security.

5. Summary: a layered, defense-in-depth approach

Prevent theft in the first place

  • HttpOnly + Secure + SameSite=Strict on your refresh cookie.
  • Strong CSP, input sanitization, minimal third-party JS.
  • Frequent security audits of any frontend code that handles tokens.

Detect & respond quickly if theft happens

  • Rotate refresh tokens on every use (one-time use) and immediately revoke the old one.
  • Log “device fingerprint” or IP whenever a refresh occurs; revoke all tokens on anomalies.
  • Maintain a “user locked” flag if you see suspicious refresh patterns.

Add step-up checks for sensitive contexts

  • CSRF token on the refresh endpoint (you already have).
  • Require a fresh MFA or password‐reentry for any “account settings” call.

Choose a single, clear lookup mechanism

  • Either “lookup by token only” (with a unique index), or “lookup by token_hash” alone. In both cases, the attacker who has the raw token can always discover the userId. What really matters is that the database will return exactly one row for that token, so you know “this belongs to user N.”

Personally, I’d do “lookup by token only” with a UNIQUE constraint on token_hash, because it’s simpler and requires fewer round trips.

Bottom‐line

Even if you bind “token + userId” together in your SQL, any attacker who already has the raw token string can trivially learn (or guess) the correct userId. The only way to truly break that chain is to prevent token theft in the first place or to implement strong step-up checks (MFA, email confirmations, device fingerprinting) that invalidate a stolen token the moment it’s used from an unfamiliar environment.