| |

REST API 12 ๐ŸŒ JWT (JSON Web Tokens) Implementation and Refresh Tokens

โฑ๏ธ Estimated read: 15 min | |

A JSON Web Token is a compact, URL-safe string that encodes a set of claims about a subject. It is defined by RFC 7519 as a representation format “intended for space constrained environments such as HTTP Authorization headers and URI query parameters” . The token is not encrypted by default. Its three partsโ€”header, payload, and signatureโ€”are Base64url-encoded, which means anyone who intercepts the token can read the claims inside . The signature is what prevents modification: the issuer signs the header and payload with a secret or private key, and the verifier checks that the signature matches before trusting any claim .

The refresh token is the companion credential that solves the lifetime problem. An access token is deliberately short-livedโ€”minutes, not daysโ€”so that a stolen token has a bounded window of usefulness. But forcing the user to log in every fifteen minutes is unacceptable. The refresh token is long-lived and is exchanged only at a dedicated endpoint for a new access token, which means it never travels with ordinary API requests and can be revoked server-side when necessary . The security of the entire scheme depends on how the refresh token is stored, rotated, and detected when it is replayed.

Key point: A JWT is a signed but readable claims container; never put secrets in the payload. Access tokens are short-lived and stateless, so they cannot be revoked before expiry. Refresh tokens are long-lived, stateful, and must be rotated on every use, with reuse detection that revokes the entire session when an old token is presented again.


Why JWT and refresh tokens are designed this way

The stateless verification problem. A resource server that receives a JWT can verify it without a database lookup. It checks the signature against the issuer’s public key, validates the exp claim, and trusts the claims if the signature is valid . This is the entire value proposition: horizontal scale without a shared session store. The cost is that revocation is not possible. An access token that has been signed remains valid until it expires, because there is no server-side list to check it against .

The algorithm confusion problem. The JWT header contains an alg field that tells the verifier which algorithm was used to sign the token. If the verifier reads that field and uses it to select the verification routine, an attacker can set alg to none and remove the signature, or set alg to HS256 and sign with the public RSA key as if it were an HMAC secret . The defense is to pass an explicit allow-list to the verifier and never let the token’s header dictate how it is validated .

The refresh token storage problem. A refresh token is a bearer credential: possession is sufficient. If it is stored in localStorage, any cross-site scripting vulnerability exposes it to an attacker who can then mint access tokens indefinitely . The correct storage for a browser is an HttpOnly, Secure, SameSite cookie, which JavaScript cannot read and which the browser sends automatically to the refresh endpoint .

The rotation and replay problem. If a refresh token is used once and never invalidated, a stolen copy remains valid for the entire lifetime of the session. Rotation solves this: every refresh issues a new refresh token and retires the old one. If the retired token is presented again, the server knows it has been replayedโ€”either by an attacker or by a client that lost the new tokenโ€”and revokes the entire session . This is the single most important control for refresh token security, and it is the one that is most often skipped.


a. The JWT structure and signing

A signed JWT has three Base64url-encoded sections separated by dots: the header, the payload (claims), and the signature. The header names the algorithm; the payload carries the claims; the signature binds the two.

// A signed JWT with three parts separated by dots
// Header: {"alg":"HS256","typ":"JWT"}
// Payload: {"sub":"1234567890","name":"Alice","iat":1516239022}
// Signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ.dX_ACtDZVh9ZKaDMLmZFURrh7o5R99-6MEu1WUFCaT0"

The RFC 7519 registered claims include iss (issuer), sub (subject), aud (audience), exp (expiration time), nbf (not before), iat (issued at), and jti (JWT ID) . The exp claim is the one that makes the access token short-lived. The verifier must check it, and the check must be performed by the library’s built-in validation rather than by application code, because clock skew and integer parsing are easy to get wrong.

// Verification with an explicit algorithm allow-list
// The algorithms array rejects "none" and prevents algorithm confusion
const jwt = require('jsonwebtoken')

try {
  // The publicKey or secret is the key the issuer used
  // The options constrain what the library will accept
  const decoded = jwt.verify(token, publicKey, {
    algorithms: ['RS256'],   // hard allow-list, never read from the token
    issuer: 'https://auth.example.com',
    audience: 'api://payments',
    clockTolerance: 30,      // seconds of allowed clock skew
  })
  // The decoded claims are trustworthy only after this point
  console.log(decoded.sub)
} catch (err) {
  // Any verification failure is a 401
  console.error('Invalid token:', err.message)
}

The algorithms array is the algorithm pinning defense. The issuer and audience options validate the iss and aud claims, which prevents a token issued for one service from being accepted by another . The clockTolerance option allows for small differences between the issuer’s clock and the verifier’s clock, which is necessary in distributed systems.

For HMAC signing, the secret must be at least 256 bits of cryptographic randomness. A human-chosen string like secret or mykey can be cracked offline with hashcat in seconds, and once the secret is known, the attacker can forge any token . For asymmetric signing, the private key stays on the issuer and the public key is distributed to verifiers, which scales better across services.


b. Refresh token rotation and reuse detection

The refresh token is an opaque string that the server stores in a hashed form. When the client presents it, the server hashes the presented value and looks it up. If the hash matches the current token for the session, the server issues a new access token and a new refresh token, and it retires the old refresh token by marking it as used.

// A simplified rotation handler
// The session record stores the current refresh token hash
// and the previous token hash within a grace window
async function rotateRefreshToken(presentedToken) {
  // Hash the presented token with the same algorithm used at issuance
  const presentedHash = hash(presentedToken)

  // Look up the session by the hash
  const session = await db.findSessionByRefreshHash(presentedHash)

  // If no session matches the current hash, check the retired hashes
  if (!session) {
    const retired = await db.findRetiredToken(presentedHash)
    if (retired) {
      // The token was already used; this is a replay
      await db.revokeSession(retired.sessionId)
      throw new Error('Refresh token replay detected')
    }
    // The token was never issued
    throw new Error('Invalid refresh token')
  }

  // The token is valid; mint a new pair
  const newAccess = signAccessToken(session.userId)
  const newRefresh = generateRefreshToken()

  // Retire the old token and store the new hash atomically
  await db.rotateSessionTokens(session.id, presentedHash, hash(newRefresh))

  return { access_token: newAccess, refresh_token: newRefresh }
}

The atomicity of the rotation is important. If the server mints a new token but fails to store its hash, the client receives a token that the server does not recognize. If the server stores the new hash but fails to send the token, the client loses its session. The rotation must be a single database transaction that retires the old hash and writes the new one together .

The reuse detection is what makes rotation meaningful. Without it, a stolen refresh token can be used alongside the legitimate one, and the server has no way to know which is which. With detection, the moment the stolen token is used, the session is revoked, and the legitimate user is logged out. The inconvenience of a forced re-login is the price of detecting the compromise .


c. Storage, expiry, and the client-side flow

The access token and the refresh token have different storage requirements. The access token can live in memory, because it is short-lived and the client can obtain a new one from the refresh token. The refresh token must be stored where JavaScript cannot reach it, because it is long-lived and a single XSS vulnerability would expose it.

// A client-side refresh interceptor
// The access token is in memory; the refresh token is in an HttpOnly cookie
let accessToken = null
let isRefreshing = false
let pendingRequests = []

async function apiRequest(url, options = {}) {
  // Attach the access token to every request
  const headers = { ...options.headers, Authorization: `Bearer ${accessToken}` }

  const response = await fetch(url, { ...options, headers })

  // If the access token is expired, the server returns 401
  if (response.status === 401) {
    // If a refresh is already in progress, queue the request
    if (isRefreshing) {
      return new Promise((resolve) => {
        pendingRequests.push(() => resolve(apiRequest(url, options)))
      })
    }

    isRefreshing = true
    try {
      // The refresh endpoint reads the HttpOnly cookie automatically
      const refreshResponse = await fetch('/auth/refresh', { method: 'POST' })
      const data = await refreshResponse.json()
      accessToken = data.access_token
      // Retry the original request
      return apiRequest(url, options)
    } catch (e) {
      // The refresh failed; the user must log in again
      accessToken = null
      throw e
    } finally {
      isRefreshing = false
      // Drain the queue
      pendingRequests.forEach((fn) => fn())
      pendingRequests = []
    }
  }

  return response
}

The queueing is the detail that prevents a thundering herd. If five requests fail with 401 at the same time, and each one calls the refresh endpoint independently, the server receives five refresh requests, and four of them present a token that has already been rotated. The server’s reuse detection may then revoke the session, logging out the user for no reason. The queue ensures that only one refresh happens, and the other requests wait for its result .

The expiry strategy is a trade-off between security and user experience. A short access token lifetime (5โ€“15 minutes) limits the damage of a stolen token but increases the frequency of refresh calls. A longer refresh token lifetime (7โ€“30 days) reduces the frequency of full logins but extends the window in which a stolen refresh token is useful. The refresh token should be rotated on every use, and the session should have an absolute maximum age after which even a valid refresh token is rejected .


Complete Example Session

The following session builds a login endpoint that issues an access token and a refresh token, a refresh endpoint that rotates the refresh token and detects replay, and a client that stores the tokens correctly.

// ============================================
// PART 1: THE LOGIN ENDPOINT
// ============================================
const jwt = require('jsonwebtoken')
const crypto = require('crypto')

async function login(req, res) {
  // Verify the username and password against the database
  const user = await db.findUser(req.body.username)
  if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  // Mint a short-lived access token
  const accessToken = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: '15m',
  })

  // Mint an opaque refresh token
  const refreshToken = crypto.randomBytes(32).toString('base64url')
  // Store only the hash, never the plain token
  await db.createSession(user.id, hash(refreshToken))

  // Send the refresh token as an HttpOnly cookie
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  })

  // Send the access token in the body
  res.json({ access_token: accessToken })
}
// ============================================
// PART 2: THE REFRESH ENDPOINT
// ============================================
async function refresh(req, res) {
  // Read the refresh token from the cookie
  const presented = req.cookies.refresh_token
  if (!presented) return res.status(401).json({ error: 'No token' })

  // Hash the presented token and find the session
  const presentedHash = hash(presented)
  const session = await db.findSessionByHash(presentedHash)

  // If the token matches a retired hash, it is a replay
  if (!session) {
    const retired = await db.findRetiredHash(presentedHash)
    if (retired) {
      await db.revokeSession(retired.sessionId)
      return res.status(401).json({ error: 'Token replay detected' })
    }
    return res.status(401).json({ error: 'Invalid token' })
  }

  // The token is valid; rotate it
  const newAccess = jwt.sign({ sub: session.userId }, process.env.JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: '15m',
  })
  const newRefresh = crypto.randomBytes(32).toString('base64url')
  await db.rotateSession(session.id, presentedHash, hash(newRefresh))

  // Send the new cookie and body
  res.cookie('refresh_token', newRefresh, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000,
  })
  res.json({ access_token: newAccess })
}
// ============================================
// PART 3: THE TOKEN HASH FUNCTION
// ============================================
function hash(token) {
  // A keyed hash prevents an attacker with database access
  // from precomputing hashes of guessed tokens
  return crypto.createHmac('sha256', process.env.REFRESH_PEPPER)
    .update(token)
    .digest('hex')
}

The three parts cover the login endpoint that issues the pair, the refresh endpoint that rotates and detects replay, and the keyed hash that protects the stored tokens. What the session demonstrates is that the access token is a signed JWT, the refresh token is opaque, the refresh token is stored hashed, and the rotation retires the old token.


Quick Reference

JWT Sections

SectionContentEncoded
Headeralg, typBase64url
PayloadClaims (sub, exp, iat)Base64url
SignatureHMAC or RSA/ECDSABase64url

Registered Claims

ClaimMeaning
issIssuer
subSubject
audAudience
expExpiration time
nbfNot before
iatIssued at
jtiJWT ID

Access vs Refresh Token

AspectAccess TokenRefresh Token
FormatJWTOpaque
Lifetime5โ€“15 minutes7โ€“30 days
StorageMemoryHttpOnly cookie
Sent with requestsYesOnly to refresh endpoint
RevocableNo (stateless)Yes (server-side)

Security Defenses

DefensePurpose
Algorithm allow-listPrevent none and confusion
Keyed hash storageProtect refresh tokens at rest
RotationLimit reuse window
Reuse detectionRevoke on replay
HttpOnly cookiePrevent XSS theft

Best Practices

โœ… Do This:

// Pin the algorithm
jwt.verify(token, key, { algorithms: ['RS256'] })

// Store only the hash
await db.createSession(user.id, hash(refreshToken))

// Rotate on every refresh
await db.rotateSession(session.id, oldHash, newHash)

// Detect replay
if (retired) await db.revokeSession(retired.sessionId)

โŒ Don’t Do This:

// Accept the algorithm from the token
jwt.verify(token, key) // no allow-list

// Store the plain refresh token
await db.createSession(user.id, refreshToken)

// Reuse the same refresh token
// return the same token without rotation

// Put secrets in the JWT payload
jwt.sign({ password: user.password }, key)

Common Pitfalls

PitfallWhy It HappensFix
none algorithm acceptedNo allow-listalgorithms: ['RS256']
Weak HMAC secretHuman-chosen string256-bit CSPRNG
Refresh token in localStorageConvenienceHttpOnly cookie
No rotationSimplicityRotate on every use
Replay not detectedNo retired-token checkStore retired hashes
Clock skew rejectionexp too strictclockTolerance: 30

Real-World Examples

1. Sign a JWT

jwt.sign({ sub: user.id }, secret, { algorithm: 'HS256', expiresIn: '15m' })

2. Verify with allow-list

jwt.verify(token, key, { algorithms: ['RS256'] })

3. Generate a refresh token

crypto.randomBytes(32).toString('base64url')

4. Hash a refresh token

crypto.createHmac('sha256', pepper).update(token).digest('hex')

5. Set an HttpOnly cookie

res.cookie('refresh_token', token, { httpOnly: true, secure: true })

6. Rotate on refresh

await db.rotateSession(id, oldHash, newHash)

7. Detect replay

if (retired) await db.revokeSession(retired.sessionId)

8. Check exp with tolerance

jwt.verify(token, key, { clockTolerance: 30 })

9. Reject none

jwt.verify(token, key, { algorithms: ['HS256'] }) // rejects none

10. Queue refresh requests

if (isRefreshing) pendingRequests.push(retryFn)

Visual

JWT Structure

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  header.payload.signature                    โ”‚
โ”‚                                              โ”‚
โ”‚  header: {"alg":"HS256","typ":"JWT"}         โ”‚
โ”‚  payload: {"sub":"123","exp":1516239022}     โ”‚
โ”‚  signature: HMAC(header.payload, secret)     โ”‚
โ”‚                                              โ”‚
โ”‚  Base64url-encoded, readable, not encrypted  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Access and Refresh Flow

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Login โ†’ access (15m) + refresh (7d)         โ”‚
โ”‚         โ”‚                                    โ”‚
โ”‚         โ–ผ                                    โ”‚
โ”‚  API calls with access token                 โ”‚
โ”‚         โ”‚                                    โ”‚
โ”‚         โ–ผ 401 on expiry                      โ”‚
โ”‚  POST /auth/refresh with refresh cookie      โ”‚
โ”‚         โ”‚                                    โ”‚
โ”‚         โ–ผ                                    โ”‚
โ”‚  New access + new refresh (rotation)         โ”‚
โ”‚  Old refresh retired                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Replay Detection

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Client presents retired refresh token       โ”‚
โ”‚         โ”‚                                    โ”‚
โ”‚         โ–ผ                                    โ”‚
โ”‚  Server: hash matches retired record         โ”‚
โ”‚         โ”‚                                    โ”‚
โ”‚         โ–ผ                                    โ”‚
โ”‚  Revoke entire session                       โ”‚
โ”‚  Return 401                                  โ”‚
โ”‚  Legitimate user must log in again           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
JWT standardRFC 7519
JWT sectionsHeader, payload, signature
JWT encodingBase64url (readable)
Access token lifetime5โ€“15 minutes
Refresh token lifetime7โ€“30 days
Access token storageMemory
Refresh token storageHttpOnly cookie
Algorithm defenseExplicit allow-list
Refresh rotationNew token on every use
Replay detectionRevoke on retired token

Key takeaways:

  • A JWT is signed, not encrypted. The claims are readable by anyone who intercepts the token. Never put secrets in the payload, and never trust a claim without verifying the signature first .
  • Algorithm pinning is mandatory. Pass an explicit algorithms array to the verifier. Never let the token’s header select the verification routine, because none and algorithm confusion attacks are trivial to exploit .
  • Access tokens are short-lived and stateless. They cannot be revoked before expiry. The short lifetime is the mitigation, and the refresh token is the mechanism that keeps the user logged in without extending that lifetime .
  • Refresh tokens must be rotated. Every refresh issues a new token and retires the old one. Without rotation, a stolen token remains valid for the entire session lifetime .
  • Replay detection is what makes rotation meaningful. If a retired token is presented again, the session is revoked. This is the control that turns a stolen token into a detectable event rather than a silent compromise .
  • Storage is not a detail. The refresh token goes in an HttpOnly cookie, not localStorage. A single XSS vulnerability in a localStorage implementation exposes the refresh token, and with it the entire session .

Remember: JWTs solve the stateless verification problem and introduce the revocation problem. The access token is the solution for scale, and the refresh token is the solution for lifetime. The security of the scheme depends on three things: the access token is short-lived and never carries secrets, the refresh token is stored where JavaScript cannot read it, and every refresh rotates the token with reuse detection that revokes the session on replay. Get the algorithm pinning wrong and an attacker forges tokens. Get the storage wrong and an XSS steals the session. Get the rotation wrong and a stolen token stays valid forever. The three controls are independent, and all three are required.



Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!