JWT Authentication Middleware for APIs
Build secure, production-ready JWT authentication for Next.js APIs. Token generation, validation, refresh tokens, role-based access control, and security best practices.
JWT Authentication Middleware for APIs
Secure API authentication is fundamental to protecting your application and user data. This guide shows you how to implement production-ready JWT authentication with refresh tokens, role-based access control, and security best practices.
Why JWT?
Benefits:
- ✅ Stateless (no server-side session storage)
- ✅ Scalable (works across multiple servers)
- ✅ Self-contained (includes user info + permissions)
- ✅ Cross-domain compatible
- ✅ Mobile-friendly
Use cases:
- API authentication
- Microservices communication
- Single-page applications (SPA)
- Mobile app backends
Complete Implementation
1. JWT Service
// lib/auth/jwt.ts
import { SignJWT, jwtVerify } from 'jose';
/**
* User payload stored in JWT
*/
export interface JWTPayload {
userId: string;
email: string;
role: 'user' | 'admin' | 'moderator';
tier?: 'free' | 'pro' | 'team';
permissions?: string[];
}
/**
* Token pair (access + refresh)
*/
export interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number; // Seconds
}
/**
* JWT configuration
*/
const JWT_CONFIG = {
accessTokenSecret: new TextEncoder().encode(
process.env.JWT_ACCESS_SECRET || 'change-this-secret-in-production'
),
refreshTokenSecret: new TextEncoder().encode(
process.env.JWT_REFRESH_SECRET || 'change-this-refresh-secret-in-production'
),
accessTokenExpiry: '15m', // 15 minutes
refreshTokenExpiry: '7d', // 7 days
issuer: 'theartofcto.com',
audience: 'api.theartofcto.com',
};
/**
* Generate access token
*/
export async function generateAccessToken(payload: JWTPayload): Promise<string> {
const token = await new SignJWT({
userId: payload.userId,
email: payload.email,
role: payload.role,
tier: payload.tier,
permissions: payload.permissions || [],
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(JWT_CONFIG.issuer)
.setAudience(JWT_CONFIG.audience)
.setExpirationTime(JWT_CONFIG.accessTokenExpiry)
.setSubject(payload.userId)
.sign(JWT_CONFIG.accessTokenSecret);
return token;
}
/**
* Generate refresh token
*/
export async function generateRefreshToken(userId: string): Promise<string> {
const token = await new SignJWT({ userId })
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setIssuer(JWT_CONFIG.issuer)
.setAudience(JWT_CONFIG.audience)
.setExpirationTime(JWT_CONFIG.refreshTokenExpiry)
.setSubject(userId)
.setJti(crypto.randomUUID()) // Unique token ID for revocation
.sign(JWT_CONFIG.refreshTokenSecret);
return token;
}
/**
* Generate token pair (access + refresh)
*/
export async function generateTokenPair(payload: JWTPayload): Promise<TokenPair> {
const [accessToken, refreshToken] = await Promise.all([
generateAccessToken(payload),
generateRefreshToken(payload.userId),
]);
return {
accessToken,
refreshToken,
expiresIn: 15 * 60, // 15 minutes in seconds
};
}
/**
* Verify access token
*/
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
try {
const { payload } = await jwtVerify(token, JWT_CONFIG.accessTokenSecret, {
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
});
return {
userId: payload.userId as string,
email: payload.email as string,
role: payload.role as 'user' | 'admin' | 'moderator',
tier: payload.tier as 'free' | 'pro' | 'team' | undefined,
permissions: payload.permissions as string[] | undefined,
};
} catch (error) {
throw new Error('Invalid or expired token');
}
}
/**
* Verify refresh token
*/
export async function verifyRefreshToken(token: string): Promise<{
userId: string;
jti: string;
}> {
try {
const { payload } = await jwtVerify(token, JWT_CONFIG.refreshTokenSecret, {
issuer: JWT_CONFIG.issuer,
audience: JWT_CONFIG.audience,
});
return {
userId: payload.userId as string,
jti: payload.jti as string,
};
} catch (error) {
throw new Error('Invalid or expired refresh token');
}
}
/**
* Decode token without verification (for debugging)
*/
export function decodeToken(token: string): any {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString('utf-8')
);
return payload;
}
2. Authentication Middleware
// lib/auth/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken, JWTPayload } from './jwt';
/**
* Extended request with user info
*/
export interface AuthenticatedRequest extends NextRequest {
user: JWTPayload;
}
/**
* Middleware options
*/
export interface AuthMiddlewareOptions {
required?: boolean; // Default: true
roles?: string[]; // Allowed roles
permissions?: string[]; // Required permissions
onUnauthorized?: (reason: string) => NextResponse;
}
/**
* Authentication middleware for API routes
*
* @example
* export const GET = withAuth(async (request: AuthenticatedRequest) => {
* const userId = request.user.userId;
* // ...
* });
*/
export function withAuth(
handler: (request: AuthenticatedRequest) => Promise<NextResponse>,
options: AuthMiddlewareOptions = {}
): (request: NextRequest) => Promise<NextResponse> {
return async (request: NextRequest): Promise<NextResponse> => {
const {
required = true,
roles,
permissions,
onUnauthorized,
} = options;
try {
// 1. Extract token from Authorization header
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
if (!required) {
// Optional auth - continue without user
return handler(request as AuthenticatedRequest);
}
return (
onUnauthorized?.('Missing Authorization header') ||
NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
)
);
}
// 2. Parse Bearer token
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
return (
onUnauthorized?.('Invalid Authorization format') ||
NextResponse.json(
{ error: 'Invalid authorization format. Use: Bearer <token>' },
{ status: 401 }
)
);
}
// 3. Verify token
let user: JWTPayload;
try {
user = await verifyAccessToken(token);
} catch (error) {
return (
onUnauthorized?.('Invalid or expired token') ||
NextResponse.json(
{ error: 'Invalid or expired token' },
{ status: 401 }
)
);
}
// 4. Check role-based access
if (roles && !roles.includes(user.role)) {
return NextResponse.json(
{ error: 'Insufficient permissions' },
{ status: 403 }
);
}
// 5. Check permission-based access
if (permissions) {
const userPermissions = user.permissions || [];
const hasPermission = permissions.every((perm) =>
userPermissions.includes(perm)
);
if (!hasPermission) {
return NextResponse.json(
{ error: 'Missing required permissions', required: permissions },
{ status: 403 }
);
}
}
// 6. Attach user to request and continue
(request as AuthenticatedRequest).user = user;
return handler(request as AuthenticatedRequest);
} catch (error) {
console.error('[Auth Middleware] Error:', error);
return NextResponse.json(
{ error: 'Authentication failed' },
{ status: 500 }
);
}
};
}
/**
* Role-based middleware helpers
*/
export const requireAdmin = (handler: any) =>
withAuth(handler, { roles: ['admin'] });
export const requireModerator = (handler: any) =>
withAuth(handler, { roles: ['admin', 'moderator'] });
export const requirePermission = (permission: string) => (handler: any) =>
withAuth(handler, { permissions: [permission] });
/**
* Optional authentication (for public endpoints with optional user context)
*/
export const withOptionalAuth = (handler: any) =>
withAuth(handler, { required: false });
3. Login Endpoint
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { generateTokenPair } from '@/lib/auth/jwt';
import { getDbPool } from '@/lib/db-pool';
import bcrypt from 'bcrypt';
const db = getDbPool();
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password required' },
{ status: 400 }
);
}
// Find user
const result = await db.query(
'SELECT id, email, password_hash, role, tier FROM users WHERE email = $1',
[email]
);
const user = result.rows[0];
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate tokens
const tokens = await generateTokenPair({
userId: user.id,
email: user.email,
role: user.role,
tier: user.tier,
});
// Store refresh token in database (for revocation)
await db.query(
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
[user.id, await hashToken(tokens.refreshToken)]
);
// Return tokens
return NextResponse.json({
user: {
id: user.id,
email: user.email,
role: user.role,
tier: user.tier,
},
...tokens,
});
} catch (error) {
console.error('[Login] Error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}
async function hashToken(token: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(token).digest('hex');
}
4. Token Refresh Endpoint
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken, generateAccessToken } from '@/lib/auth/jwt';
import { getDbPool } from '@/lib/db-pool';
const db = getDbPool();
export async function POST(request: NextRequest) {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return NextResponse.json(
{ error: 'Refresh token required' },
{ status: 400 }
);
}
// Verify refresh token
const { userId, jti } = await verifyRefreshToken(refreshToken);
// Check if token is revoked
const tokenHash = await hashToken(refreshToken);
const result = await db.query(
`SELECT user_id, revoked, expires_at
FROM refresh_tokens
WHERE token_hash = $1 AND user_id = $2`,
[tokenHash, userId]
);
if (result.rows.length === 0) {
return NextResponse.json(
{ error: 'Invalid refresh token' },
{ status: 401 }
);
}
const tokenRecord = result.rows[0];
if (tokenRecord.revoked) {
return NextResponse.json(
{ error: 'Token has been revoked' },
{ status: 401 }
);
}
if (new Date(tokenRecord.expires_at) < new Date()) {
return NextResponse.json(
{ error: 'Refresh token expired' },
{ status: 401 }
);
}
// Get user data
const userResult = await db.query(
'SELECT id, email, role, tier FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 401 }
);
}
// Generate new access token
const accessToken = await generateAccessToken({
userId: user.id,
email: user.email,
role: user.role,
tier: user.tier,
});
return NextResponse.json({
accessToken,
expiresIn: 15 * 60, // 15 minutes
});
} catch (error) {
console.error('[Refresh] Error:', error);
return NextResponse.json(
{ error: 'Token refresh failed' },
{ status: 401 }
);
}
}
async function hashToken(token: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(token).digest('hex');
}
5. Logout Endpoint
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, AuthenticatedRequest } from '@/lib/auth/middleware';
import { getDbPool } from '@/lib/db-pool';
const db = getDbPool();
export const POST = withAuth(async (request: AuthenticatedRequest) => {
try {
const { refreshToken } = await request.json();
const userId = request.user.userId;
if (refreshToken) {
// Revoke specific refresh token
const tokenHash = await hashToken(refreshToken);
await db.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE token_hash = $1 AND user_id = $2',
[tokenHash, userId]
);
} else {
// Revoke all refresh tokens for user
await db.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = $1',
[userId]
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('[Logout] Error:', error);
return NextResponse.json(
{ error: 'Logout failed' },
{ status: 500 }
);
}
});
async function hashToken(token: string): Promise<string> {
const crypto = await import('crypto');
return crypto.createHash('sha256').update(token).digest('hex');
}
6. Protected API Route Examples
// app/api/users/me/route.ts
import { NextResponse } from 'next/server';
import { withAuth, AuthenticatedRequest } from '@/lib/auth/middleware';
import { getDbPool } from '@/lib/db-pool';
const db = getDbPool();
/**
* GET /api/users/me - Get current user
*/
export const GET = withAuth(async (request: AuthenticatedRequest) => {
const userId = request.user.userId;
const result = await db.query(
'SELECT id, email, name, role, tier, created_at FROM users WHERE id = $1',
[userId]
);
const user = result.rows[0];
return NextResponse.json({ user });
});
/**
* PATCH /api/users/me - Update current user
*/
export const PATCH = withAuth(async (request: AuthenticatedRequest) => {
const userId = request.user.userId;
const updates = await request.json();
// Only allow updating specific fields
const allowedFields = ['name', 'bio'];
const updates_filtered = Object.keys(updates)
.filter((key) => allowedFields.includes(key))
.reduce((obj: any, key) => {
obj[key] = updates[key];
return obj;
}, {});
if (Object.keys(updates_filtered).length === 0) {
return NextResponse.json(
{ error: 'No valid fields to update' },
{ status: 400 }
);
}
// Build UPDATE query
const fields = Object.keys(updates_filtered)
.map((key, i) => `${key} = $${i + 2}`)
.join(', ');
const values = Object.values(updates_filtered);
const result = await db.query(
`UPDATE users SET ${fields}, updated_at = NOW() WHERE id = $1 RETURNING *`,
[userId, ...values]
);
return NextResponse.json({ user: result.rows[0] });
});
// app/api/admin/users/route.ts
import { NextResponse } from 'next/server';
import { requireAdmin, AuthenticatedRequest } from '@/lib/auth/middleware';
import { getDbPool } from '@/lib/db-pool';
const db = getDbPool();
/**
* GET /api/admin/users - List all users (admin only)
*/
export const GET = requireAdmin(async (request: AuthenticatedRequest) => {
const result = await db.query(
'SELECT id, email, name, role, tier, created_at FROM users ORDER BY created_at DESC'
);
return NextResponse.json({ users: result.rows });
});
Database Schema
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
role VARCHAR(50) DEFAULT 'user',
tier VARCHAR(50) DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Refresh tokens table
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) UNIQUE NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
-- Clean up expired tokens (run periodically)
DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days';
Client Usage
JavaScript/TypeScript Client
// lib/api-client.ts
class ApiClient {
private baseUrl = '/api';
private accessToken: string | null = null;
private refreshToken: string | null = null;
async login(email: string, password: string) {
const response = await fetch(`${this.baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
const data = await response.json();
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
// Store in localStorage
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
return data;
}
async logout() {
await fetch(`${this.baseUrl}/auth/logout`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ refreshToken: this.refreshToken }),
});
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
async refreshAccessToken() {
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken }),
});
if (!response.ok) {
// Refresh token expired - redirect to login
this.logout();
throw new Error('Session expired');
}
const data = await response.json();
this.accessToken = data.accessToken;
localStorage.setItem('accessToken', data.accessToken);
return data;
}
async fetch(path: string, options: RequestInit = {}) {
// Add authorization header
const headers = this.getHeaders();
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...headers, ...options.headers },
});
// Handle 401 - try refreshing token
if (response.status === 401) {
await this.refreshAccessToken();
// Retry request with new token
return fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...this.getHeaders(), ...options.headers },
});
}
return response;
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
return headers;
}
// Initialize from localStorage
init() {
this.accessToken = localStorage.getItem('accessToken');
this.refreshToken = localStorage.getItem('refreshToken');
}
}
export const apiClient = new ApiClient();
Security Best Practices
✅ DO
- Use HTTPS - Always use HTTPS in production
- Short access token expiry - 15 minutes or less
- Longer refresh token expiry - 7 days to 30 days
- Store tokens securely - httpOnly cookies (preferred) or localStorage
- Hash refresh tokens - Never store raw refresh tokens in DB
- Implement token rotation - Issue new refresh token on refresh
- Add CSRF protection - For cookie-based auth
- Rate limit auth endpoints - Prevent brute force attacks
- Log authentication events - For security monitoring
- Implement MFA - For sensitive operations
❌ DON'T
- Don't store sensitive data in JWT - Tokens are readable
- Don't use weak secrets - Use strong, random secrets
- Don't skip token validation - Always verify signature and claims
- Don't ignore expiry - Check
expclaim - Don't share secrets - Different secrets for access/refresh tokens
Testing
// __tests__/auth/jwt.test.ts
import { generateTokenPair, verifyAccessToken } from '@/lib/auth/jwt';
describe('JWT Service', () => {
it('should generate and verify access token', async () => {
const payload = {
userId: 'user-123',
email: 'test@example.com',
role: 'user' as const,
};
const tokens = await generateTokenPair(payload);
const verified = await verifyAccessToken(tokens.accessToken);
expect(verified.userId).toBe(payload.userId);
expect(verified.email).toBe(payload.email);
expect(verified.role).toBe(payload.role);
});
it('should reject expired token', async () => {
// Test with expired token
const expiredToken = 'eyJhbGc...'; // Generate expired token
await expect(verifyAccessToken(expiredToken)).rejects.toThrow(
'Invalid or expired token'
);
});
});
Summary
✅ JWT authentication provides stateless, scalable API security ✅ Access tokens are short-lived (15 minutes) ✅ Refresh tokens enable long-lived sessions (7 days) ✅ Middleware simplifies route protection ✅ Role-based access controls admin/user permissions ✅ Token revocation allows logout and security controls
Production checklist:
- [ ] HTTPS enabled
- [ ] Strong JWT secrets configured
- [ ] Refresh token storage implemented
- [ ] Token expiry configured (15m access, 7d refresh)
- [ ] Rate limiting on auth endpoints
- [ ] Authentication logging enabled
- [ ] CORS configured correctly
- [ ] Token cleanup job scheduled
Further reading: