JWT Access Token vs Refresh Token

Complete guide to understanding, implementing, and securing JWT access and refresh tokens

SecurityJune 23, 202616 min readBy Keyur Patel

Modern web and mobile applications use JWT (JSON Web Token) authentication to identify users without server-side sessions. But a single JWT creates a fundamental dilemma: make it short-lived (secure but annoying — users get logged out constantly) or long-lived (convenient but dangerous — a stolen token grants extended access). Access tokens and refresh tokens solve this dilemma by splitting authentication into two tokens with different lifespans and purposes.

This guide explains how access tokens and refresh tokens work together, their security properties, storage strategies, rotation mechanisms, and practical implementation with Node.js code examples.

What Is an Access Token?

An access token is a short-lived JWT that proves the user's identity on every API request. It contains the user's ID, role, permissions, and an expiration time. The server verifies the access token's signature on each request — if valid and not expired, the request is authorized.

  • Purpose: Authorize individual API requests
  • Typical lifespan: 5-30 minutes
  • Sent with: Every API request (Authorization: Bearer header)
  • Contains: User ID, role, permissions, email, expiration
  • Stored: Memory (SPA) or httpOnly cookie (web)

Access Token Payload Example

// Decoded access token payload
{
  "sub": "user_123",          // Subject (user ID)
  "email": "alice@example.com",
  "role": "admin",
  "permissions": ["read", "write", "delete"],
  "iat": 1719216000,          // Issued at (Unix timestamp)
  "exp": 1719216900           // Expires in 15 minutes
}

Because access tokens are sent with every request and verified without a database lookup, they must be short-lived. If stolen, the damage is limited to the remaining lifetime (minutes, not days).

What Is a Refresh Token?

A refresh token is a long-lived credential used exclusively to obtain new access tokens. It is not sent with regular API requests — it is only used when the access token expires. The refresh token is stored securely (httpOnly cookie or secure device storage) and sent to a dedicated token refresh endpoint.

  • Purpose: Get new access tokens without re-login
  • Typical lifespan: 7-30 days
  • Sent to: Only the /auth/refresh endpoint (never regular APIs)
  • Contains: Minimal data — usually just a token ID and user reference
  • Stored: httpOnly cookie (web) or encrypted storage (mobile)

Why Refresh Tokens Are Needed

Without refresh tokens, you have two bad options:

❌ Short access token, no refresh

User gets logged out every 15 minutes. Terrible experience — user rage-quits your app.

❌ Long access token, no refresh

Token stolen = attacker has access for days/weeks. Cannot revoke. Security nightmare.

✅ Short access token + refresh token

Access token expires in 15 min (limits damage). Refresh token silently gets a new one (seamless UX). Refresh token can be revoked server-side (security control).

Real-world analogy: An access token is like a hotel key card that expires daily. A refresh token is like your reservation confirmation — you show it at the front desk to get a new key card each day without checking in again.

Access Token vs Refresh Token: Side-by-Side Comparison

FeatureAccess TokenRefresh Token
PurposeAuthorize API requestsObtain new access tokens
Expiration5-30 minutes7-30 days
Sent withEvery API requestOnly token refresh endpoint
Payload sizeLarge (contains claims)Small (just ID/reference)
Storage (web)Memory or httpOnly cookiehttpOnly cookie only
Storage (mobile)Secure memoryEncrypted device storage
Security if stolenLimited damage (short life)Serious damage (long life)
Revocable?No (stateless)Yes (server stores reference)
Server validationSignature check only (no DB)Database/Redis lookup required
Frequency of useEvery request (hundreds/day)Every 15 min (few times/day)
Should be a JWT?Always (for stateless verification)Optional (can be opaque string)
Contains user data?Yes (ID, role, permissions)Minimal (just token ID)

Complete Authentication Flow

Here is the full lifecycle of access and refresh tokens working together:

1. LOGIN

User sends email + password → Server returns access token + refresh token

2. API REQUESTS

Client sends access token with every request → Server verifies signature → Authorized

3. ACCESS TOKEN EXPIRES (15 min)

Server returns 401 Unauthorized → Client detects expiration

4. SILENT REFRESH

Client sends refresh token to /auth/refresh → Server issues new access token + new refresh token

5. CONTINUE

Client retries the failed request with new access token → User never noticed

The key UX insight: the user never sees any of this. The token refresh happens automatically in the background. From the user's perspective, they log in once and stay logged in for days or weeks — seamlessly.

Implementation with Node.js

const jwt = require("jsonwebtoken")
const crypto = require("crypto")

const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET

// Generate token pair on login
function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    ACCESS_SECRET,
    { expiresIn: "15m" }
  )
  
  const refreshToken = jwt.sign(
    { sub: user.id, tokenId: crypto.randomUUID() },
    REFRESH_SECRET,
    { expiresIn: "7d" }
  )
  
  return { accessToken, refreshToken }
}

// Login endpoint
app.post("/auth/login", async (req, res) => {
  const { email, password } = req.body
  const user = await db.findUser(email)
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: "Invalid credentials" })
  }
  
  const { accessToken, refreshToken } = generateTokens(user)
  
  // Store refresh token in database (for revocation)
  await db.storeRefreshToken(refreshToken, user.id)
  
  // Set refresh token as httpOnly cookie
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true, secure: true, sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000, path: "/auth"
  })
  
  // Return access token in body (stored in memory by client)
  res.json({ accessToken })
})

// Refresh endpoint
app.post("/auth/refresh", async (req, res) => {
  const { refreshToken } = req.cookies
  if (!refreshToken) return res.status(401).json({ error: "No refresh token" })
  
  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET)
    
    // Check if token exists in database (not revoked)
    const stored = await db.findRefreshToken(refreshToken)
    if (!stored) return res.status(401).json({ error: "Token revoked" })
    
    // Rotation: invalidate old refresh token
    await db.deleteRefreshToken(refreshToken)
    
    // Issue new token pair
    const user = await db.findUserById(payload.sub)
    const newTokens = generateTokens(user)
    await db.storeRefreshToken(newTokens.refreshToken, user.id)
    
    res.cookie("refreshToken", newTokens.refreshToken, {
      httpOnly: true, secure: true, sameSite: "strict",
      maxAge: 7 * 24 * 60 * 60 * 1000, path: "/auth"
    })
    res.json({ accessToken: newTokens.accessToken })
  } catch {
    return res.status(401).json({ error: "Invalid refresh token" })
  }
})

// Logout — revoke refresh token
app.post("/auth/logout", (req, res) => {
  const { refreshToken } = req.cookies
  if (refreshToken) db.deleteRefreshToken(refreshToken)
  res.clearCookie("refreshToken", { path: "/auth" })
  res.json({ message: "Logged out" })
})

Where Should Tokens Be Stored?

StorageXSS RiskCSRF RiskPersists?Best For
httpOnly CookieNone (JS can't read)Possible (use SameSite)YesRefresh tokens ✅
localStorageHigh (JS can read)NoneYes❌ Never for tokens
sessionStorageHigh (JS can read)NoneTab only❌ Never for tokens
Memory (variable)Low (lost on refresh)NoneNoAccess tokens ✅ (SPA)
Secure device storageNone (native)NoneYesMobile apps ✅

The recommended pattern for web applications: store the refresh token in an httpOnly cookie (sent only to /auth paths) and keep the access token in JavaScript memory. On page reload, the SPA calls /auth/refresh to get a new access token using the cookie.

Refresh Token Rotation

Refresh token rotation is a critical security mechanism where every time a refresh token is used, it is invalidated and a brand new refresh token is issued alongside the new access token. The old refresh token can never be used again.

Why Rotation Matters

Without rotation, a stolen refresh token gives the attacker persistent access for the entire token lifetime (days or weeks). With rotation, theft is detectable:

Scenario: Attacker steals refresh token R1

WITHOUT rotation:
  Attacker uses R1 → gets access token (works)
  Attacker uses R1 again → gets access token (still works!)
  Attacker has unlimited access until R1 expires (days)
  
WITH rotation:
  Attacker uses R1 → gets access token + new refresh token R2
  Real user tries to use R1 → FAILS (R1 was already used)
  Server detects reuse → REVOKES entire token family
  Both attacker (R2) and real user are forced to re-login
  Damage contained

The key insight: token reuse detection. If a refresh token is used twice, someone is either the attacker or the victim — either way, revoking the entire family is the safe response.

Implementation Pattern

Store refresh tokens in a database with a family ID. When rotation detects reuse (a token that has already been used is presented again), delete all tokens belonging to that family:

// Database schema for refresh tokens
// tokens table: { id, token_hash, user_id, family_id, used, expires_at }

async function refreshWithRotation(oldToken) {
  const stored = await db.findToken(hash(oldToken))
  
  if (!stored) throw new Error("Token not found")
  
  if (stored.used) {
    // REUSE DETECTED — revoke entire family
    await db.deleteTokenFamily(stored.family_id)
    throw new Error("Token reuse detected — all sessions revoked")
  }
  
  // Mark old token as used (not deleted — kept for reuse detection)
  await db.markAsUsed(stored.id)
  
  // Issue new token in same family
  const newToken = generateRefreshToken()
  await db.storeToken({
    token_hash: hash(newToken),
    user_id: stored.user_id,
    family_id: stored.family_id, // Same family
    used: false,
    expires_at: addDays(7)
  })
  
  return newToken
}

Security Considerations

Token Theft Scenarios

XSS steals access token from localStorage

Impact: Attacker has full API access for token lifetime

Prevention: Never store tokens in localStorage. Use httpOnly cookies or memory.

Network interception (man-in-the-middle)

Impact: Attacker captures token in transit

Prevention: Always use HTTPS. Set Secure flag on cookies.

Refresh token stolen from cookie

Impact: Attacker can generate unlimited access tokens

Prevention: Use refresh token rotation. Limit refresh token to specific path (/auth).

CSRF forges request with cookie

Impact: Unauthorized actions using victim's session

Prevention: SameSite=Strict on cookies. Use CSRF tokens for state-changing operations.

Expiration Best Practices

Application TypeAccess TokenRefresh Token
Banking / Healthcare5 minutes1 day (with re-auth for sensitive ops)
SaaS application15 minutes7 days
Social media / Content30 minutes30 days
Mobile app15 minutes90 days (with biometric re-verification)
Machine-to-machine API1 hourNot needed (use client credentials)

Common Mistakes Developers Make

⚠️ Using long-lived access tokens (24h+) without refresh tokens

Defeats the entire security model. A stolen 24-hour access token gives an attacker full access with no way to revoke it. Always use short access tokens (15 min) + refresh tokens.

⚠️ Storing tokens in localStorage

Any XSS vulnerability exposes all tokens. A single injected script reads localStorage and sends tokens to the attacker's server. Use httpOnly cookies that JavaScript cannot access.

⚠️ Not implementing refresh token rotation

Without rotation, a stolen refresh token grants permanent access until expiration. With rotation, theft is detectable and the entire token family can be revoked immediately.

⚠️ Putting sensitive data in JWT access token payload

JWT payloads are Base64 encoded — anyone can decode them. Never include passwords, credit cards, social security numbers, or internal system details in the payload.

⚠️ Not checking token expiration on the client

Waiting for a 401 response to discover token expiration adds unnecessary latency. Proactively refresh the token 30-60 seconds before expiration for seamless UX.

⚠️ Using the same secret for access and refresh tokens

If the access token secret is compromised, the attacker can forge refresh tokens too. Use separate signing keys for each token type.

Best Practices

Use short-lived access tokens (15 min)

Limits the damage window if a token is stolen. The attacker can only use it for minutes, not days.

Implement refresh token rotation

Issue a new refresh token on every refresh. Detect and revoke on reuse. This is the single most important security measure.

Store refresh tokens in httpOnly cookies

JavaScript cannot read httpOnly cookies, protecting against XSS attacks. Set SameSite=Strict and Secure flags.

Use separate signing secrets

Different secrets for access and refresh tokens. If one is compromised, the other remains secure.

Validate all claims (exp, iss, aud)

Don't trust a token just because the signature is valid. Check expiration, issuer, and audience on every request.

Implement proper logout

Delete the refresh token from the server store and clear the cookie. The access token will expire naturally within minutes.

Use HTTPS everywhere

Tokens sent over HTTP can be intercepted by anyone on the network. Enforce HTTPS in production without exceptions.

Proactively refresh before expiration

Client-side: check if the access token expires within 60 seconds and refresh preemptively. Avoids the 401 → refresh → retry cycle.

Limit refresh token scope

Set the cookie path to /auth so the refresh token is only sent to authentication endpoints — not with every API request.

Frequently Asked Questions

What is the difference between an access token and a refresh token?
An access token is a short-lived token (5-30 minutes) sent with every API request to prove identity. A refresh token is a long-lived token (days/weeks) used only to obtain new access tokens when they expire. Access tokens are used frequently, refresh tokens are used rarely.
Can refresh tokens expire?
Yes. Refresh tokens should have an expiration (typically 7-30 days). When both access and refresh tokens expire, the user must log in again. This prevents stolen refresh tokens from granting indefinite access.
Should refresh tokens be JWTs?
Not necessarily. Refresh tokens can be opaque random strings stored in a database, which allows easy revocation. Making them JWTs adds stateless verification but makes revocation harder. Many systems use opaque refresh tokens for better security control.
Where should refresh tokens be stored?
Store refresh tokens in httpOnly, Secure, SameSite=Strict cookies for web applications. Never store them in localStorage (vulnerable to XSS). For mobile apps, use the platform's secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).
Can I use only access tokens without refresh tokens?
Yes, but with trade-offs. Short-lived access tokens without refresh tokens force frequent re-logins (poor UX). Long-lived access tokens without refresh tokens create security risks (stolen token = long-term access with no revocation). Refresh tokens solve both problems.
What is refresh token rotation?
Refresh token rotation means issuing a new refresh token every time the current one is used, and invalidating the old one. If a stolen refresh token is used after the legitimate user already used it, the server detects reuse and revokes the entire token family.
What happens if a refresh token is stolen?
With rotation: the attacker uses the stolen token once, gets a new pair. When the legitimate user tries to use their (now-invalid) old token, the server detects reuse and revokes all tokens for that user, forcing re-login. Without rotation: the attacker has persistent access until the token expires.
How do I implement logout with JWT tokens?
Delete the refresh token from the server-side store (database/Redis) and clear it from the client cookie. The access token remains valid until expiration (typically 15 min), but without the refresh token, the user cannot get new access tokens after it expires.

Related Articles & Tools

Conclusion

Access tokens and refresh tokens work as a team to solve the fundamental tension between security (short-lived credentials) and user experience (staying logged in). Access tokens handle the high-frequency work of authorizing every API request — they are short-lived, stateless, and cannot be revoked. Refresh tokens handle the low-frequency work of maintaining the user's session — they are long-lived, stored server-side, and fully revocable.

The essential implementation checklist: use 15-minute access tokens, 7-day refresh tokens with rotation, httpOnly cookie storage for web, separate signing secrets, proper expiration validation, and HTTPS everywhere. With these fundamentals in place, you have a secure, scalable authentication system that provides seamless user experience while maintaining strict security controls.