Skip to main content
Featured

JWT Authentication Middleware for APIs

November 23, 2025•By The CTO•30 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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] });
});
typescript
// 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

sql
-- 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

typescript
// 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

typescript
// __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:

Accounts are opening soon

Save your tool results, track your scores over time, and get your invite before the public launch. One email, nothing else.

No spam. We only email you about your invite.