Introduction
Short-lived access tokens are secure but annoying—users would need to log in every 15 minutes. Refresh tokens solve this by enabling silent token renewal without re-entering credentials.
Key Concepts
Two-token system:
- Access Token: Short-lived (15 minutes), used for API requests
- Refresh Token: Long-lived (7 days), used only to get new access tokens
Why separate them?
- Access tokens are used frequently and exposed to more attack surfaces
- Refresh tokens are used rarely and can be stored more securely
Real World Context
The refresh pattern is essential for:
- Mobile apps that need to stay logged in
- SPAs that make frequent API calls
- Any app balancing security (short tokens) with UX (staying logged in)
Deep Dive
Token Creation
typescript// lib/tokens.ts import { SignJWT, jwtVerify } from 'jose'; import { randomUUID } from 'crypto'; const accessSecret = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET); const refreshSecret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET); export async function createTokens(userId: string, role: string) { const accessToken = await new SignJWT({ userId, role }) .setProtectedHeader({ alg: 'HS256' }) .setExpirationTime('15m') .sign(accessSecret); const refreshToken = await new SignJWT({ userId, tokenId: randomUUID() }) .setProtectedHeader({ alg: 'HS256' }) .setExpirationTime('7d') .sign(refreshSecret); return { accessToken, refreshToken }; }
Login: Issue Both Tokens
typescript// app/api/auth/login/route.ts export async function POST(request: Request) { const { email, password } = await request.json(); const user = await validateCredentials(email, password); if (!user) { return Response.json({ error: 'Invalid credentials' }, { status: 401 }); } const { accessToken, refreshToken } = await createTokens(user.id, user.role); // Store refresh token in database for revocation capability await prisma.refreshToken.create({ data: { token: refreshToken, userId: user.id }, }); const cookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, path: '/', }; const cookieStore = await cookies(); cookieStore.set('access_token', accessToken, { ...cookieOptions, maxAge: 60 * 15, // 15 minutes }); cookieStore.set('refresh_token', refreshToken, { ...cookieOptions, maxAge: 60 * 60 * 24 * 7, // 7 days }); return Response.json({ success: true }); }
Refresh Endpoint
typescript// app/api/auth/refresh/route.ts import { jwtVerify } from 'jose'; import { createTokens } from '@/lib/tokens'; export async function POST(request: Request) { const cookieStore = await cookies(); const refreshToken = cookieStore.get('refresh_token')?.value; if (!refreshToken) { return Response.json({ error: 'No refresh token' }, { status: 401 }); } try { // Verify refresh token const { payload } = await jwtVerify(refreshToken, refreshSecret); // Check if token is in database (not revoked) const storedToken = await prisma.refreshToken.findUnique({ where: { token: refreshToken }, include: { user: true }, }); if (!storedToken) { return Response.json({ error: 'Invalid refresh token' }, { status: 401 }); } // Rotate: Delete old, create new await prisma.refreshToken.delete({ where: { token: refreshToken } }); const { accessToken, refreshToken: newRefreshToken } = await createTokens(storedToken.userId, storedToken.user.role); await prisma.refreshToken.create({ data: { token: newRefreshToken, userId: storedToken.userId }, }); // Set new cookies const updatedCookieStore = await cookies(); updatedCookieStore.set('access_token', accessToken, { /* ... */ }); updatedCookieStore.set('refresh_token', newRefreshToken, { /* ... */ }); return Response.json({ success: true }); } catch { return Response.json({ error: 'Invalid token' }, { status: 401 }); } }
Proxy with Auto-Refresh
typescript// proxy.ts export async function proxy(request: NextRequest) { const accessToken = request.cookies.get('access_token')?.value; const refreshToken = request.cookies.get('refresh_token')?.value; if (accessToken) { try { const { payload } = await jwtVerify(accessToken, accessSecret); const response = NextResponse.next(); response.headers.set('x-user-id', payload.userId as string); return response; } catch { // Access token expired, try refresh } } // No valid access token - redirect to login // (Client-side will handle refresh via API call) if (!refreshToken) { return NextResponse.redirect(new URL('/login', request.url)); } // Has refresh token but no valid access token // Could auto-refresh here or let client handle it return NextResponse.redirect(new URL('/auth/refresh', request.url)); }
Common Pitfalls
-
Not rotating refresh tokens: Reusing the same refresh token forever means a stolen token has unlimited lifetime. Always issue a new one.
-
Storing refresh tokens client-side: Refresh tokens in localStorage can be stolen via XSS. Use HttpOnly cookies.
-
Not storing refresh tokens server-side: Without server storage, you can't revoke tokens on logout or security events.
Best Practices
- Rotate refresh tokens on each use: Delete the old one, issue a new one
- Store refresh tokens in the database: Enables revocation and audit trails
- Use different secrets for access and refresh tokens: Compromising one doesn't compromise the other
- Clear all tokens on password change: Revoke all refresh tokens when security-sensitive actions occur
Summary
The refresh token pattern combines short-lived access tokens (security) with long-lived refresh tokens (convenience). Store refresh tokens server-side for revocation capability, rotate them on each use, and use HttpOnly cookies for both. This pattern gives you the best of both JWTs and sessions.