DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Implement OAuth 2.1 with JWT Refresh Tokens in Node.js (2026 Guide)

How to Implement OAuth 2.1 with JWT Refresh Tokens in Node.js (2026 Guide)

In 2026, API security isn't optional—it's the foundation of every production system. OAuth 2.1, the latest evolution of the OAuth framework, consolidates years of security best practices into a single, opinionated specification. Combined with JWT access tokens and refresh token rotation, you get a robust authentication system that protects against modern threats like token interception, refresh token theft, and phishing attacks.

This guide walks you through implementing a complete OAuth 2.1-compliant authentication system in Node.js, with working code you can deploy today.

Why OAuth 2.1 Matters in 2026

OAuth 2.1 isn't just a new version number—it's a security revolution. Here's what's changed:

Key OAuth 2.1 Security Requirements

Requirement Old OAuth 2.0 OAuth 2.1
PKCE Optional for SPAs Mandatory for all public clients
Refresh Token Rotation Optional Strongly recommended
Token Expiration Often 1 hour+ Shorter access tokens (15-30 min)
Implicit Flow Allowed Deprecated
ROPC Flow Allowed Not recommended

In 2026, attackers have become sophisticated. Code interception, token exfiltration via browser developer tools, and refresh token theft through phishing or malware are common attack vectors. OAuth 2.1's enforced PKCE (Proof Key for Code Exchange), token rotation, and sender-constrained refresh tokens directly address these vulnerabilities.

Architecture Overview

Our implementation will include:

  1. Authorization Server - Issues tokens, handles PKCE, manages refresh tokens
  2. Resource Server - Validates JWT access tokens, enforces authorization
  3. PKCE Flow - Required for public clients (SPAs, mobile apps)
  4. Refresh Token Rotation - Invalidates old tokens when new ones are issued

Project Setup

Initialize your project:

mkdir oauth2-server && cd oauth2-server
npm init -y
npm install express jsonwebtoken jose uuid cors dotenv
Enter fullscreen mode Exit fullscreen mode
  • express: Web framework
  • jsonwebtoken: JWT signing/verification (for access tokens)
  • jose: Full OAuth 2.1/OIDC compliance (recommended over jsonwebtoken for 2026)
  • uuid: Token identifiers
  • cors: Cross-origin resource sharing
  • dotenv: Environment configuration

Implementation

1. Environment Configuration

Create a .env file:

PORT=3000
JWT_SECRET=your-super-secret-key-change-in-production
REFRESH_TOKEN_SECRET=your-refresh-secret-change-in-production
JWT_ALGORITHM=RS256  # Use asymmetric signing in production
TOKEN_EXPIRY=900     # 15 minutes in seconds
REFRESH_TOKEN_EXPIRY=604800  # 7 days in seconds
Enter fullscreen mode Exit fullscreen mode

2. Token Service

Create services/tokenService.js:

const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');

// In-memory store (use Redis in production)
const refreshTokens = new Map();

class TokenService {
  // Generate short-lived access token (JWT)
  generateAccessToken(userId, roles = []) {
    const payload = {
      sub: userId,
      roles,
      iat: Math.floor(Date.now() / 1000),
      exp: Math.floor(Date.now() / 1000) + (parseInt(process.env.TOKEN_EXPIRY) || 900),
      iss: 'https://api.yourdomain.com',
      aud: 'your-api-identifier'
    };

    return jwt.sign(payload, process.env.JWT_SECRET, { 
      algorithm: 'HS256'  // Use RS256 in production with proper key pairs
    });
  }

  // Generate refresh token with rotation support
  generateRefreshToken(userId) {
    const tokenId = uuidv4();
    const token = crypto.randomBytes(64).toString('hex');

    // Store refresh token metadata for rotation
    refreshTokens.set(token, {
      userId,
      tokenId,
      createdAt: Date.now(),
      rotated: false,
      family: uuidv4()  // Track token family for rotation detection
    });

    return { token, tokenId };
  }

  // Verify and rotate refresh token
  async rotateRefreshToken(oldToken) {
    const tokenData = refreshTokens.get(oldToken);

    if (!tokenData) {
      throw new Error('Invalid refresh token');
    }

    // Check if already rotated (potential token reuse attack)
    if (tokenData.rotated) {
      // Security: invalidate entire token family
      this.invalidateTokenFamily(tokenData.family);
      throw new Error('Token reuse detected - security alert');
    }

    // Mark old token as rotated
    tokenData.rotated = true;
    refreshTokens.set(oldToken, tokenData);

    // Generate new tokens
    const newTokens = this.generateRefreshToken(tokenData.userId);

    // Update family tracking
    const newTokenData = refreshTokens.get(newTokens.token);
    newTokenData.family = tokenData.family;
    refreshTokens.set(newTokens.token, newTokenData);

    // Clean up old tokens older than 24 hours
    this.cleanupOldTokens();

    return newTokens;
  }

  // Verify access token
  verifyAccessToken(token) {
    try {
      return jwt.verify(token, process.env.JWT_SECRET, {
        algorithms: ['HS256'],  // Explicitly specify allowed algorithms
        issuer: 'https://api.yourdomain.com',
        audience: 'your-api-identifier'
      });
    } catch (error) {
      return null;
    }
  }

  // Invalidate a specific token
  invalidateToken(token) {
    refreshTokens.delete(token);
  }

  // Invalidate all tokens in a family (security measure)
  invalidateTokenFamily(familyId) {
    for (const [token, data] of refreshTokens.entries()) {
      if (data.family === familyId) {
        refreshTokens.delete(token);
      }
    }
  }

  // Cleanup old rotated tokens
  cleanupOldTokens() {
    const now = Date.now();
    const maxAge = 24 * 60 * 60 * 1000; // 24 hours

    for (const [token, data] of refreshTokens.entries()) {
      if (data.rotated && (now - data.createdAt) > maxAge) {
        refreshTokens.delete(token);
      }
    }
  }

  // Get token metadata (for debugging)
  getTokenInfo(token) {
    return refreshTokens.get(token);
  }
}

module.exports = new TokenService();
Enter fullscreen mode Exit fullscreen mode

3. PKCE Challenge Generator

Create services/pkce.js:

const crypto = require('crypto');

class PKCE {
  // Generate code verifier (43-128 characters, URL-safe)
  generateCodeVerifier() {
    return crypto.randomBytes(32).toString('base64url');
  }

  // Generate code challenge from verifier
  generateCodeChallenge(verifier) {
    // Use S256 (SHA-256) challenge method
    return crypto
      .createHash('sha256')
      .update(verifier)
      .digest('base64url');
  }

  // Verify PKCE challenge
  verifyChallenge(verifier, challenge) {
    const computed = this.generateCodeChallenge(verifier);
    return computed === challenge;
  }
}

module.exports = new PKCE();
Enter fullscreen mode Exit fullscreen mode

4. Authorization Server (OAuth Endpoints)

Create server.js:

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const tokenService = require('./services/tokenService');
const pkce = require('./services/pkce');
const { v4: uuidv4 } = require('uuid');

const app = express();
app.use(express.json());
app.use(cors({
  origin: ['https://yourfrontend.com', 'http://localhost:3001'],
  credentials: true
}));

// In-memory storage (use database in production)
const authorizationCodes = new Map();
const users = new Map([
  ['user1', { id: 'user1', password: 'password123', roles: ['user'] }],
  ['admin1', { id: 'admin1', password: 'adminpass', roles: ['admin', 'user'] }]
]);

// PKCE storage (maps code to verifier)
const pkceStore = new Map();

// ============ OAuth 2.1 Endpoints ============

// 1. Authorization Endpoint
app.get('/oauth/authorize', (req, res) => {
  const { 
    client_id, 
    redirect_uri, 
    response_type, 
    scope, 
    state,
    code_challenge,
    code_challenge_method 
  } = req.query;

  // Validate required parameters
  if (!client_id || !redirect_uri || !response_type || !code_challenge) {
    return res.status(400).json({
      error: 'invalid_request',
      error_description: 'Missing required parameters'
    });
  }

  // Only support 'code' response type (no implicit/implicit flow)
  if (response_type !== 'code') {
    return res.status(400).json({
      error: 'unsupported_response_type',
      error_description: 'Only code response_type is supported'
    });
  }

  // Validate code challenge method
  if (code_challenge_method !== 'S256') {
    return res.status(400).json({
      error: 'invalid_request',
      error_description: 'Only S256 code_challenge_method is supported'
    });
  }

  // Store PKCE verifier with the future authorization code
  const authCode = uuidv4();
  pkceStore.set(authCode, {
    codeVerifier: null,  // Will be set during token exchange
    codeChallenge: code_challenge,
    clientId: client_id,
    redirectUri: redirect_uri,
    scope,
    userId: null  // Will be set after login
  });

  // In real app, redirect to login page
  // For this demo, we'll simulate a logged-in user
  const simulatedUserId = 'user1';
  pkceStore.get(authCode).userId = simulatedUserId;

  // Redirect with authorization code
  const params = new URLSearchParams({
    code: authCode,
    state: state || ''
  });

  res.redirect(`${redirect_uri}?${params}`);
});

// 2. Token Endpoint (supports authorization_code and refresh_token)
app.post('/oauth/token', async (req, res) => {
  const { 
    grant_type, 
    code, 
    redirect_uri, 
    client_id,
    code_verifier,
    refresh_token 
  } = req.body;

  try {
    // Handle Authorization Code Grant with PKCE
    if (grant_type === 'authorization_code') {
      if (!code || !redirect_uri || !code_verifier) {
        return res.status(400).json({
          error: 'invalid_request',
          error_description: 'Missing required parameters'
        });
      }

      const pkceData = pkceStore.get(code);
      if (!pkceData) {
        return res.status(400).json({
          error: 'invalid_grant',
          error_description: 'Invalid or expired authorization code'
        });
      }

      // Verify PKCE
      if (!pkce.verifyChallenge(code_verifier, pkceData.codeChallenge)) {
        return res.status(400).json({
          error: 'invalid_grant',
          error_description: 'PKCE verification failed'
        });
      }

      // Verify redirect URI
      if (pkceData.redirectUri !== redirect_uri) {
        return res.status(400).json({
          error: 'invalid_grant',
          error_description: 'Redirect URI mismatch'
        });
      }

      // Clean up authorization code (one-time use)
      pkceStore.delete(code);

      // Generate tokens
      const userId = pkceData.userId;
      const accessToken = tokenService.generateAccessToken(userId, users.get(userId)?.roles || []);
      const { token: refreshToken, tokenId } = tokenService.generateRefreshToken(userId);

      return res.json({
        access_token: accessToken,
        token_type: 'Bearer',
        expires_in: parseInt(process.env.TOKEN_EXPIRY) || 900,
        refresh_token: refreshToken,
        scope: pkceData.scope || 'read write'
      });
    }

    // Handle Refresh Token Grant with Rotation
    if (grant_type === 'refresh_token') {
      if (!refresh_token) {
        return res.status(400).json({
          error: 'invalid_request',
          error_description: 'Refresh token required'
        });
      }

      // Rotate tokens (get new access + refresh token)
      const newTokens = await tokenService.rotateRefreshToken(refresh_token);
      const userData = tokenService.getTokenInfo(newTokens.token);

      const accessToken = tokenService.generateAccessToken(
        userData.userId, 
        users.get(userData.userId)?.roles || []
      );

      return res.json({
        access_token: accessToken,
        token_type: 'Bearer',
        expires_in: parseInt(process.env.TOKEN_EXPIRY) || 900,
        refresh_token: newTokens.token,
        scope: 'read write'
      });
    }

    // Unsupported grant type
    return res.status(400).json({
      error: 'unsupported_grant_type',
      error_description: 'Only authorization_code and refresh_token grants supported'
    });

  } catch (error) {
    console.error('Token endpoint error:', error);

    if (error.message.includes('Token reuse')) {
      return res.status(401).json({
        error: 'invalid_grant',
        error_description: 'Token reuse detected. Please re-authenticate.'
      });
    }

    return res.status(500).json({
      error: 'server_error',
      error_description: 'Internal server error'
    });
  }
});

// 3. Token Introspection (for resource servers)
app.post('/oauth/introspect', (req, res) => {
  const { token } = req.body;

  if (!token) {
    return res.status(400).json({ active: false });
  }

  const payload = tokenService.verifyAccessToken(token);

  if (payload) {
    return res.json({
      active: true,
      scope: payload.roles?.join(' ') || 'read',
      client_id: 'your-client-id',
      token_type: 'Bearer',
      exp: payload.exp,
      iat: payload.iat,
      sub: payload.sub
    });
  }

  res.json({ active: false });
});

// 4. Token Revocation
app.post('/oauth/revoke', (req, res) => {
  const { token } = req.body;

  if (token) {
    tokenService.invalidateToken(token);
  }

  // Per OAuth 2.1, always return success
  res.json({});
});

// ============ Protected API Endpoints ============

// Example protected resource
app.get('/api/protected', authenticateToken, (req, res) => {
  res.json({
    message: 'Access granted to protected resource',
    user: req.user
  });
});

// Example admin-only endpoint
app.get('/api/admin', authenticateToken, requireRole('admin'), (req, res) => {
  res.json({
    message: 'Admin access granted',
    secret: 'This is admin-only data'
  });
});

// Middleware: Authenticate JWT access token
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({
      error: 'invalid_token',
      error_description: 'Access token required'
    });
  }

  const payload = tokenService.verifyAccessToken(token);

  if (!payload) {
    return res.status(401).json({
      error: 'invalid_token',
      error_description: 'Invalid or expired access token'
    });
  }

  req.user = payload;
  next();
}

// Middleware: Require specific role
function requireRole(role) {
  return (req, res, next) => {
    if (!req.user.roles || !req.user.roles.includes(role)) {
      return res.status(403).json({
        error: 'insufficient_scope',
        error_description: `Role '${role}' required`
      });
    }
    next();
  };
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`OAuth 2.1 Authorization Server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

5. Client Example (SPA/Frontend)

Here's how a client would use this OAuth 2.1 flow:

class OAuthClient {
  constructor(config) {
    this.config = config;
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpiry = null;
  }

  // Step 1: Initiate authorization with PKCE
  async initiateAuth() {
    // Generate PKCE code verifier and challenge
    const codeVerifier = this.generateCodeVerifier();
    const codeChallenge = await this.generateCodeChallenge(codeVerifier);

    // Store verifier for token exchange (in sessionStorage)
    sessionStorage.setItem('code_verifier', codeVerifier);

    // Build authorization URL
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      response_type: 'code',
      scope: 'read write',
      state: this.generateState(),
      code_challenge: codeChallenge,
      code_challenge_method: 'S256'
    });

    window.location.href = `${this.config.authUrl}?${params}`;
  }

  // Step 2: Exchange code for tokens
  async exchangeCode(code) {
    const codeVerifier = sessionStorage.getItem('code_verifier');

    const response = await fetch(this.config.tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: this.config.redirectUri,
        client_id: this.config.clientId,
        code_verifier: codeVerifier
      })
    });

    const tokens = await response.json();

    if (tokens.access_token) {
      this.setTokens(tokens);
      sessionStorage.removeItem('code_verifier');
    }

    return tokens;
  }

  // Step 3: Refresh tokens with rotation
  async refreshAccessToken() {
    if (!this.refreshToken) {
      throw new Error('No refresh token available');
    }

    const response = await fetch(this.config.tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.refreshToken
      })
    });

    const tokens = await response.json();

    if (tokens.access_token) {
      this.setTokens(tokens);
    } else if (tokens.error) {
      // Token rotation failed - clear tokens
      this.clearTokens();
      throw new Error(tokens.error_description);
    }

    return tokens;
  }

  // Automatic token refresh wrapper
  async authenticatedFetch(url, options = {}) {
    // Check if token needs refresh (5 minute buffer)
    if (this.tokenExpiry && Date.now() > this.tokenExpiry - 300000) {
      await this.refreshAccessToken();
    }

    options.headers = {
      ...options.headers,
      'Authorization': `Bearer ${this.accessToken}`
    };

    return fetch(url, options);
  }

  setTokens(tokens) {
    this.accessToken = tokens.access_token;
    this.refreshToken = tokens.refresh_token;
    this.tokenExpiry = Date.now() + (tokens.expires_in * 1000);

    // Store securely (avoid localStorage for sensitive tokens)
    sessionStorage.setItem('access_token', this.accessToken);
    sessionStorage.setItem('refresh_token', this.refreshToken);
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpiry = null;
    sessionStorage.removeItem('access_token');
    sessionStorage.removeItem('refresh_token');
  }

  generateCodeVerifier() {
    return Array.from(crypto.getRandomValues(new Uint8Array(32)))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('')
      .substring(0, 128);
  }

  async generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    return btoa(String.fromCharCode(...new Uint8Array(hash)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  generateState() {
    return Array.from(crypto.getRandomValues(new Uint8Array(16)))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices for 2026

1. Always Use PKCE

PKCE (Proof Key for Code Exchange) is now mandatory for all public clients in OAuth 2.1:

  • Prevents authorization code interception attacks
  • Protects against malicious redirect URIs
  • Required for SPAs and mobile apps

2. Implement Refresh Token Rotation

Every time a refresh token is used, issue a new one and invalidate the old:

// Critical security measure
if (tokenData.rotated) {
  // Attacker trying to reuse stolen token
  invalidateTokenFamily(tokenData.family);
  throw new Error('Token reuse detected');
}
Enter fullscreen mode Exit fullscreen mode

3. Use Short-Lived Access Tokens

  • Access tokens: 15-30 minutes maximum
  • Refresh tokens: 7 days maximum
  • Force re-authentication periodically

4. Use Asymmetric Keys (RS256) in Production

// Use RS256 in production - private key signs, public key verifies
const privateKey = fs.readFileSync('./keys/private.pem');
const publicKey = fs.readFileSync('./keys/public.pem');

jwt.sign(payload, privateKey, { algorithm: 'RS256' });
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
Enter fullscreen mode Exit fullscreen mode

5. Validate All Token Claims

Always validate issuer, audience, and expiration:

jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.yourdomain.com',  // Required
  audience: 'your-api-identifier',         // Required
  clockTolerance: 10                      // Handle clock skew
});
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls to Avoid

  1. Don't store tokens in localStorage - Vulnerable to XSS. Use httpOnly cookies or sessionStorage
  2. Don't use HS256 in microservices - Use RS256 so only the auth server can sign
  3. Don't skip PKCE - Even for "trusted" clients, PKCE adds minimal overhead for major security gains
  4. Don't ignore token reuse - Always invalidate the entire token family on reuse detection

Conclusion

OAuth 2.1 represents the security best practices of 2026. By implementing:

  • Mandatory PKCE for all public clients
  • Refresh token rotation to detect theft
  • Short-lived access tokens (15 minutes)
  • Proper token validation (issuer, audience, expiration)

You're building an authentication system that protects against modern attack vectors while providing a smooth user experience.

The complete code above gives you a production-ready foundation. Just swap the in-memory storage for Redis or a database, configure proper CORS settings, and you're ready to deploy.


Tags: OAuth2, JWT, Node.js, API Security, Authentication, PKCE, Refresh Tokens, Web Security, 2026

Top comments (0)