Skip to main content
Featured

JWT Authentication Middleware for APIs

November 23, 2025By Steve Winter30 min read
...
code

Build secure, production-ready JWT authentication for Next.js APIs. Token generation, validation, refresh tokens, role-based access control, and security best practices.

Language: TypeScript
Difficulty: intermediate

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

  1. Use HTTPS - Always use HTTPS in production
  2. Short access token expiry - 15 minutes or less
  3. Longer refresh token expiry - 7 days to 30 days
  4. Store tokens securely - httpOnly cookies (preferred) or localStorage
  5. Hash refresh tokens - Never store raw refresh tokens in DB
  6. Implement token rotation - Issue new refresh token on refresh
  7. Add CSRF protection - For cookie-based auth
  8. Rate limit auth endpoints - Prevent brute force attacks
  9. Log authentication events - For security monitoring
  10. Implement MFA - For sensitive operations

❌ DON'T

  1. Don't store sensitive data in JWT - Tokens are readable
  2. Don't use weak secrets - Use strong, random secrets
  3. Don't skip token validation - Always verify signature and claims
  4. Don't ignore expiry - Check exp claim
  5. 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: