Skip to main content
All articles
Backend & API Security

How I Designed a Secure Authentication System in Express.js

A practical breakdown of how I designed a secure, production-ready authentication system in Express.js using JWT, bcrypt, crypto, and role-based access control, focusing on real-world security decisions and common pitfalls.

How I Designed a Secure Authentication System in Express.js

1. Introduction: Why Authentication Security Matters

After spending years building backend systems, I've learned one fundamental truth: authentication is not just a feature—it's the security foundation of your entire application. Get it wrong, and you're not just risking data breaches; you're compromising user trust, business reputation, and potentially exposing yourself to legal liabilities.

I've seen too many applications in the wild with critical authentication vulnerabilities:

  • Passwords stored in plain text in databases, waiting to be exposed in a breach
  • Long-lived tokens that never expire, giving attackers unlimited time to exploit stolen credentials
  • Authorization checks done client-side, allowing users to simply modify their role in localStorage
  • Predictable token patterns that can be guessed or brute-forced
  • Insufficient rate limiting on login endpoints, enabling credential stuffing attacks

These aren't theoretical vulnerabilities—they're real-world mistakes I've encountered during security audits and code reviews. Each one represents a potential disaster waiting to happen.

That's why I decided to build a production-ready authentication system from the ground up, not just another tutorial-level implementation. This blog post documents the journey, the decisions I made, and the lessons I learned along the way.

Key Insight: Authentication is the gatekeeper to your application. If it fails, everything behind it is compromised—user data, business logic, and system integrity.


2. System Overview and Design Goals

High-Level Architecture

Before writing a single line of code, I established clear design goals for the authentication system:

  1. Security First: Every design decision prioritizes security over convenience
  2. Scalability: The system should handle thousands of concurrent users without degradation
  3. Maintainability: Clean architecture with clear separation of concerns
  4. Token-Based Authentication: Stateless design for horizontal scaling
  5. Role-Based Access Control: Fine-grained authorization beyond simple authentication

Technology Stack

I chose the following technologies based on proven security track records and industry standards:

  • Express.js: Lightweight, flexible Node.js framework for building the API
  • bcrypt: Industry-standard password hashing with adaptive complexity
  • jsonwebtoken (JWT): Stateless access token generation and verification
  • crypto (Node.js built-in): Cryptographically secure random token generation
  • MongoDB/PostgreSQL: For storing user credentials and refresh tokens securely

Why Token-Based Over Session-Based Authentication?

I deliberately chose token-based authentication for several critical reasons:

  • Stateless Design: No server-side session storage required, enabling easy horizontal scaling
  • Cross-Domain Support: Tokens work seamlessly across multiple subdomains and APIs
  • Mobile-Friendly: Native mobile apps handle tokens more naturally than cookies
  • Microservices Ready: Tokens can be validated independently by multiple services

However, token-based auth comes with its own challenges, particularly around token invalidation and refresh strategies—challenges I address throughout this system.


3. User Registration and Password Security

The Cardinal Rule: Never Store Plain Text Passwords

This should be obvious, but I'll say it anyway: storing passwords in plain text is inexcusable. Even if you "trust" your database security, breaches happen. When they do, plain text passwords expose not just your users, but every other account where they've reused that password.

Why bcrypt for Password Hashing?

I chose bcrypt for password hashing because it's specifically designed to be slow and computationally expensive, making brute-force attacks impractical. Here's what makes bcrypt excellent:

  • Adaptive Complexity: You can increase the computational cost over time as hardware improves
  • Built-in Salt: Each password gets a unique salt automatically, preventing rainbow table attacks
  • Industry Standard: Battle-tested and recommended by security experts worldwide
  • Slow by Design: Takes ~250ms to hash, making mass password cracking infeasible

Choosing the Right Salt Rounds

The salt rounds (also called cost factor) determine how computationally expensive the hash operation is. Each increment doubles the time required.

My choice: 12 rounds

Here's my reasoning:

  • 10 rounds: Too fast on modern hardware (~100ms), vulnerable to distributed attacks
  • 12 rounds: Sweet spot (~250-300ms), secure but not frustrating for users
  • 14+ rounds: Potentially too slow, may cause timeout issues under load

Registration Flow Implementation

Here's the core registration logic:

// User Registration Controller
const registerUser = async (req, res) => {
  try {
    const { email, password, username } = req.body;

    // Check if user already exists
    const existingUser = await User.findOne({ 
      $or: [{ email }, { username }] 
    });
    
    if (existingUser) {
      return res.status(409).json({ 
        error: 'User already exists' 
      });
    }

    // Hash password with 12 salt rounds
    const hashedPassword = await bcrypt.hash(password, 12);

    // Create new user
    const user = await User.create({
      email,
      username,
      password: hashedPassword,
      role: 'user' // default role
    });

    // Remove password from response
    const userResponse = user.toObject();
    delete userResponse.password;

    res.status(201).json({
      message: 'User registered successfully',
      user: userResponse
    });

  } catch (error) {
    res.status(500).json({ 
      error: 'Registration failed' 
    });
  }
};

Critical Security Decisions in Registration

  1. No password requirements enforcement in code: I validate password strength on the client and through database constraints, but never reject weak passwords server-side during registration—users should have freedom, but I warn them about security risks
  2. Generic error messages: "User already exists" doesn't reveal whether it's the email or username that's taken, preventing account enumeration
  3. Never return the password: Even the hashed password stays out of API responses
  4. Default role assignment: New users always start as 'user', never 'admin' or 'employee'

4. Login Flow and Credential Verification

The Login Process

Login is where most authentication attacks occur. My implementation focuses on security while maintaining usability:

// Login Controller
const loginUser = async (req, res) => {
  try {
    const { email, password } = req.body;

    // Find user by email
    const user = await User.findOne({ email }).select('+password');
    
    if (!user) {
      return res.status(401).json({ 
        error: 'Invalid credentials' 
      });
    }

    // Verify password using bcrypt
    const isPasswordValid = await bcrypt.compare(
      password, 
      user.password
    );

    if (!isPasswordValid) {
      return res.status(401).json({ 
        error: 'Invalid credentials' 
      });
    }

    // Generate access token (JWT)
    const accessToken = generateAccessToken(user);

    // Generate refresh token (crypto)
    const refreshToken = generateRefreshToken();

    // Store refresh token in database
    await RefreshToken.create({
      token: refreshToken,
      userId: user._id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });

    // Return tokens
    res.json({
      accessToken,
      refreshToken,
      user: {
        id: user._id,
        email: user.email,
        role: user.role
      }
    });

  } catch (error) {
    res.status(500).json({ 
      error: 'Login failed' 
    });
  }
};

Secure Password Comparison

Using bcrypt.compare() is crucial because it performs constant-time comparison, preventing timing attacks where attackers could determine if they're getting "warmer" based on response times.

Preventing Information Leakage

Notice that whether the user doesn't exist or the password is wrong, I return the exact same error: "Invalid credentials". This is intentional.

Why this matters:

  • Prevents account enumeration (attackers can't determine which emails are registered)
  • Doesn't give attackers information about why their attempt failed
  • Forces attackers to guess both email AND password, doubling their work

Rate Limiting Login Attempts

I implement aggressive rate limiting on the login endpoint specifically:

  • 5 attempts per IP per 15 minutes for general protection
  • 3 failed attempts per email per 15 minutes for account-specific protection
  • Exponential backoff after repeated failures from the same IP

This protects against:

  • Brute-force password guessing
  • Credential stuffing attacks (using leaked passwords from other breaches)
  • Distributed attacks from multiple IPs (partially mitigated by per-email limits)

5. JWT Access Token Design

Purpose of Access Tokens

Access tokens are short-lived credentials that prove a user's identity for individual API requests. Think of them as temporary badges that grant access to specific resources.

My JWT Payload Structure

I keep JWT payloads minimal to avoid exposing sensitive data and keep token size small:

const generateAccessToken = (user) => {
  const payload = {
    userId: user._id,
    email: user.email,
    role: user.role
  };

  return jwt.sign(
    payload,
    process.env.JWT_SECRET,
    { 
      expiresIn: '15m',  // Short-lived
      issuer: 'my-app-name',
      audience: 'my-app-users'
    }
  );
};

What I Include (and Why)

  • userId: Essential for identifying the user in database queries
  • email: Useful for logging and debugging, not sensitive enough to matter if exposed
  • role: Enables authorization decisions without additional database queries

What I Deliberately Exclude

  • Password hashes: Absolutely never include these
  • Personal information: Names, phone numbers, addresses stay out
  • Permissions arrays: Too large, can cause token bloat
  • Session data: Defeats the purpose of stateless tokens

Important: Remember that JWTs are encoded, not encrypted. Anyone with the token can decode and read the payload. Only include data you'd be comfortable displaying publicly.

Token Expiration Strategy

I chose 15-minute expiration for access tokens. Here's my reasoning:

  • Short enough: Limits the damage if a token is stolen
  • Long enough: Doesn't annoy users with constant re-authentication
  • Refresh tokens handle longevity: Users stay logged in through token refresh, not long-lived access tokens

Signing and Verification

JWT signing uses HMAC SHA-256 by default. The secret must be:

  • At least 256 bits (32 characters) long
  • Cryptographically random (use a password generator, not a passphrase)
  • Stored in environment variables, never hardcoded
  • Rotated periodically (I rotate quarterly as part of security maintenance)

Verification happens in authentication middleware:

const verifyAccessToken = (token) => {
  try {
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET,
      {
        issuer: 'my-app-name',
        audience: 'my-app-users'
      }
    );
    return decoded;
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('Token expired');
    }
    if (error.name === 'JsonWebTokenError') {
      throw new Error('Invalid token');
    }
    throw error;
  }
};

6. Refresh Token Strategy and Token Rotation

Why Refresh Tokens Are Essential

Short-lived access tokens create a problem: users would need to log in every 15 minutes. That's terrible UX. Refresh tokens solve this by enabling long-term sessions while maintaining security.

The two-token strategy:

  • Access Token: Short-lived (15 minutes), used for API requests, stateless
  • Refresh Token: Long-lived (7 days), used only to obtain new access tokens, stored server-side

Generating Cryptographically Secure Refresh Tokens

I use Node.js's built-in crypto module to generate refresh tokens because:

  • It's cryptographically secure (CSPRNG)
  • Generates truly random, unpredictable tokens
  • No external dependencies required
const crypto = require('crypto');

const generateRefreshToken = () => {
  // Generate 32 bytes of random data, convert to hex
  return crypto.randomBytes(32).toString('hex');
};

This produces a 64-character hexadecimal string with 2^256 possible combinations—virtually impossible to guess or brute-force.

Storing Refresh Tokens Securely

Unlike JWTs, refresh tokens must be stored server-side to enable revocation. My storage strategy:

// RefreshToken Schema
const refreshTokenSchema = new Schema({
  token: {
    type: String,
    required: true,
    unique: true,
    index: true  // for fast lookups
  },
  userId: {
    type: ObjectId,
    ref: 'User',
    required: true
  },
  expiresAt: {
    type: Date,
    required: true,
    index: true  // for cleanup jobs
  },
  createdAt: {
    type: Date,
    default: Date.now
  },
  lastUsedAt: {
    type: Date
  },
  deviceInfo: {
    userAgent: String,
    ipAddress: String
  }
});

Token Refresh Endpoint

const refreshAccessToken = async (req, res) => {
  try {
    const { refreshToken } = req.body;

    // Find refresh token in database
    const storedToken = await RefreshToken.findOne({ 
      token: refreshToken 
    });

    if (!storedToken) {
      return res.status(401).json({ 
        error: 'Invalid refresh token' 
      });
    }

    // Check if token expired
    if (storedToken.expiresAt < new Date()) {
      await RefreshToken.deleteOne({ _id: storedToken._id });
      return res.status(401).json({ 
        error: 'Refresh token expired' 
      });
    }

    // Get user
    const user = await User.findById(storedToken.userId);

    if (!user) {
      return res.status(401).json({ 
        error: 'User not found' 
      });
    }

    // Generate new access token
    const newAccessToken = generateAccessToken(user);

    // Update last used timestamp
    storedToken.lastUsedAt = new Date();
    await storedToken.save();

    res.json({ accessToken: newAccessToken });

  } catch (error) {
    res.status(500).json({ 
      error: 'Token refresh failed' 
    });
  }
};

Token Rotation on Refresh

For enhanced security, I implement token rotation: each time a refresh token is used, I issue a new one and invalidate the old one:

// Enhanced refresh with rotation
const newRefreshToken = generateRefreshToken();

await RefreshToken.create({
  token: newRefreshToken,
  userId: user._id,
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});

// Delete old refresh token
await RefreshToken.deleteOne({ _id: storedToken._id });

res.json({ 
  accessToken: newAccessToken,
  refreshToken: newRefreshToken  // new refresh token
});

Why rotation matters:

  • If a refresh token is stolen, it can only be used once before becoming invalid
  • Legitimate users get a new token and continue working
  • The attacker's stolen token becomes worthless immediately
  • Suspicious activity (multiple tokens used) can trigger security alerts

Logout and Token Invalidation

const logoutUser = async (req, res) => {
  try {
    const { refreshToken } = req.body;

    // Delete refresh token from database
    await RefreshToken.deleteOne({ token: refreshToken });

    res.json({ message: 'Logged out successfully' });

  } catch (error) {
    res.status(500).json({ 
      error: 'Logout failed' 
    });
  }
};

Client-side, the app must also delete both tokens from storage to complete the logout.


7. Role-Based Access Control (RBAC)

Why Authorization Matters Beyond Authentication

Authentication answers "who are you?" Authorization answers "what can you do?"

Just because a user is authenticated doesn't mean they should access everything. RBAC provides fine-grained control over what different user types can do in your system.

Defining Roles in My System

I implemented three primary roles:

Role Permissions Use Case
user Read and modify own data only Regular customers, standard account holders
employee Read user data, limited write access Customer support, operations staff
admin Full access to all resources System administrators, senior management

Storing Roles

Roles are stored in the user document and included in the JWT payload:

const userSchema = new Schema({
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  role: { 
    type: String, 
    enum: ['user', 'employee', 'admin'],
    default: 'user'
  },
  createdAt: { type: Date, default: Date.now }
});

Real-World Role-Based Access Examples

Example 1: Users can only access their own profile

// GET /api/profile/:userId
router.get('/profile/:userId', 
  authenticateToken,  // verify JWT
  (req, res) => {
    // Check if user is accessing their own profile
    if (req.user.userId !== req.params.userId) {
      // Unless they're an admin or employee
      if (!['admin', 'employee'].includes(req.user.role)) {
        return res.status(403).json({ 
          error: 'Access denied' 
        });
      }
    }

    // Proceed to fetch profile
    // ...
  }
);

Example 2: Only admins can delete users

// DELETE /api/users/:userId
router.delete('/users/:userId',
  authenticateToken,
  requireRole('admin'),  // custom middleware
  async (req, res) => {
    await User.findByIdAndDelete(req.params.userId);
    res.json({ message: 'User deleted' });
  }
);

Example 3: Employees can view user data but not modify it

// GET /api/users (list all users)
router.get('/users',
  authenticateToken,
  requireRole(['admin', 'employee']),
  async (req, res) => {
    const users = await User.find().select('-password');
    res.json({ users });
  }
);

// PUT /api/users/:userId (update user)
router.put('/users/:userId',
  authenticateToken,
  requireRole('admin'),  // only admins can modify
  async (req, res) => {
    // Update logic
  }
);

Critical RBAC Security Principle

NEVER trust role information sent from the client. Always validate roles from the JWT payload, which is signed and verified server-side. Client-side role checks are for UX only (hiding buttons), never for security.


8. Authentication and Authorization Middleware Architecture

Separation of Concerns

I built two distinct middleware layers:

  1. Authentication Middleware: Verifies that the request contains a valid JWT
  2. Authorization Middleware: Checks if the authenticated user has permission for the requested action

This separation keeps code clean, reusable, and easy to test.

Authentication Middleware: Verifying JWTs

const authenticateToken = (req, res, next) => {
  try {
    // Extract token from Authorization header
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

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

    // Verify token
    const decoded = jwt.verify(
      token, 
      process.env.JWT_SECRET
    );

    // Attach user info to request object
    req.user = {
      userId: decoded.userId,
      email: decoded.email,
      role: decoded.role
    };

    next(); // proceed to next middleware or route handler

  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ 
        error: 'Token expired' 
      });
    }

    return res.status(403).json({ 
      error: 'Invalid token' 
    });
  }
};

Authorization Middleware: Role Checking

I created a flexible middleware factory that accepts single roles or arrays of roles:

const requireRole = (allowedRoles) => {
  return (req, res, next) => {
    // Ensure user is authenticated first
    if (!req.user) {
      return res.status(401).json({ 
        error: 'Authentication required' 
      });
    }

    // Convert single role to array for consistent handling
    const roles = Array.isArray(allowedRoles) 
      ? allowedRoles 
      : [allowedRoles];

    // Check if user's role is in allowed roles
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ 
        error: 'Insufficient permissions' 
      });
    }

    next();
  };
};

Using Middleware in Routes

Here's how these middleware functions compose elegantly in Express routes:

// Public route - no authentication
router.post('/register', registerUser);
router.post('/login', loginUser);

// Protected route - authentication required
router.get('/profile', 
  authenticateToken, 
  getUserProfile
);

// Admin-only route - authentication + authorization
router.delete('/users/:id', 
  authenticateToken,
  requireRole('admin'),
  deleteUser
);

// Multiple roles allowed
router.get('/dashboard',
  authenticateToken,
  requireRole(['admin', 'employee']),
  getDashboardData
);

Benefits of This Architecture

  • DRY Principle: Authentication logic written once, used everywhere
  • Clear Intent: Routes explicitly declare their security requirements
  • Easy Testing: Middleware can be tested in isolation
  • Flexible Composition: Mix and match authentication and authorization as needed
  • Maintainability: Security logic changes in one place, not scattered across route handlers

Error Handling Strategy

Notice the distinction between status codes:

  • 401 Unauthorized: Authentication failed (no token, expired token, invalid token)
  • 403 Forbidden: Authorization failed (authenticated but lacks permission)

This distinction helps clients understand exactly what went wrong and how to respond.


9. Common Authentication Mistakes and How I Avoided Them

Through code reviews and security audits, I've encountered these mistakes repeatedly. Here's how I specifically avoided them:

Mistake #1: Using Long-Lived Access Tokens

The Problem: Setting access tokens to expire in 30 days, 90 days, or never.

Why It's Dangerous: If an access token is stolen (XSS attack, intercepted request, leaked logs), the attacker has unlimited time to use it. Long-lived tokens are essentially permanent backdoors.

My Solution:

  • Access tokens expire in 15 minutes
  • Refresh tokens handle longevity (7 days)
  • Refresh tokens can be revoked server-side
  • Token rotation limits refresh token reuse

Mistake #2: Exposing Sensitive Information in JWT Payloads

The Problem: Including passwords, credit card numbers, social security numbers, or other PII in JWTs.

Why It's Dangerous: JWTs are base64-encoded, not encrypted. Anyone with the token can decode it and read the payload. This is not hypothetical—decoded JWTs are trivial with online tools or a simple script.

My Solution:

  • Only include: userId, email, role
  • No personal information, payment data, or secrets
  • Treat JWT payloads as public data
  • Use database queries for sensitive data retrieval

Mistake #3: Storing Tokens Insecurely on the Client

The Problem: Storing tokens in localStorage or sessionStorage makes them vulnerable to XSS attacks. Any malicious script on your page can steal tokens.

Why It's Dangerous: XSS vulnerabilities are common, especially when rendering user-generated content. If an attacker injects malicious JavaScript, they can exfiltrate all tokens stored in localStorage.

My Solution:

  • httpOnly cookies for refresh tokens (JavaScript cannot access them)
  • Memory storage for access tokens in React/Vue state (gone on page refresh)
  • Short expiration minimizes damage if tokens are stolen
  • Content Security Policy (CSP) headers to prevent XSS

Note: I chose not to implement httpOnly cookies in this system to maintain compatibility with mobile apps and cross-origin APIs, but for web-only applications, httpOnly cookies are superior.

Mistake #4: Trusting Client-Side Role Data

The Problem: Checking user roles from localStorage or client state instead of verifying them server-side.

Why It's Dangerous: Attackers can trivially modify localStorage data. Imagine storing { role: 'user' } in localStorage—an attacker just opens DevTools and changes it to { role: 'admin' }.

My Solution:

  • Roles stored in signed JWTs (tamper-proof)
  • Server-side middleware verifies roles from JWT, never from request body or query params
  • Client-side role checks are UX-only (hiding buttons), never security
  • All authorization decisions made server-side

Mistake #5: Insufficient Rate Limiting

The Problem: No rate limiting on authentication endpoints allows unlimited login attempts.

Why It's Dangerous: Enables brute-force attacks, credential stuffing (trying leaked passwords), and account enumeration at scale.

My Solution:

  • Aggressive rate limiting on /login, /register, /refresh endpoints
  • IP-based and email-based rate limits
  • Exponential backoff after repeated failures
  • CAPTCHA after multiple failed attempts (optional, for high-risk scenarios)

Mistake #6: Poor Error Messages

The Problem: Returning "Email not found" vs. "Incorrect password" tells attackers which part of the credential pair is wrong.

Why It's Dangerous: Enables account enumeration (discovering which emails are registered) and reduces the attacker's search space (they now only need to guess passwords).

My Solution:

  • Generic error messages: "Invalid credentials" for all authentication failures
  • No distinction between "user doesn't exist" and "wrong password"
  • Detailed errors logged server-side for debugging, not sent to clients

10. Security Best Practices Applied

1. Short-Lived Access Tokens with Secure Refresh Handling

As discussed extensively, my token strategy balances security and usability:

  • 15-minute access tokens limit exposure window
  • 7-day refresh tokens enable persistent sessions
  • Token rotation prevents replay attacks
  • Server-side storage enables revocation

2. Environment Variables for Secrets

All sensitive configuration lives in environment variables, never hardcoded:

# .env file (never committed to git)
JWT_SECRET=your-super-secret-jwt-key-min-32-chars
REFRESH_TOKEN_SECRET=different-secret-for-refresh-tokens
DATABASE_URL=mongodb://localhost:27017/myapp
PORT=3000
NODE_ENV=production

Loading environment variables:

require('dotenv').config();

// Validate critical secrets exist
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be at least 32 characters');
}

if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is required');
}

Security implications:

  • Secrets not exposed in source code or git history
  • Different secrets per environment (development, staging, production)
  • Easy secret rotation without code changes
  • Prevents accidental secret exposure in logs or error messages

3. Rate Limiting Authentication Endpoints

I use the express-rate-limit package with custom configuration:

const rateLimit = require('express-rate-limit');

// General API rate limiter
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: 'Too many requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false
});

// Strict limiter for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // only 5 attempts per window
  skipSuccessfulRequests: true, // only count failed attempts
  message: 'Too many login attempts, please try again later'
});

// Apply to routes
app.use('/api/', apiLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);

4. Proper Error Handling and Logging

I implemented a centralized error handling strategy:

// Error logging middleware
const errorLogger = (err, req, res, next) => {
  // Log detailed error server-side
  console.error({
    timestamp: new Date().toISOString(),
    method: req.method,
    path: req.path,
    error: err.message,
    stack: err.stack,
    userId: req.user?.userId
  });

  next(err);
};

// Error response middleware
const errorResponder = (err, req, res, next) => {
  // Never expose stack traces or detailed errors to clients
  res.status(err.statusCode || 500).json({
    error: process.env.NODE_ENV === 'production' 
      ? 'Internal server error' 
      : err.message
  });
};

app.use(errorLogger);
app.use(errorResponder);

Key principles:

  • Detailed logging server-side for debugging
  • Generic error messages to clients
  • Never expose stack traces in production
  • Include request context (method, path, user) in logs

5. Input Validation and Sanitization

All user input is validated before processing:

const { body, validationResult } = require('express-validator');

// Registration validation
router.post('/register',
  [
    body('email')
      .isEmail()
      .normalizeEmail()
      .withMessage('Valid email required'),
    body('password')
      .isLength({ min: 8 })
      .withMessage('Password must be at least 8 characters'),
    body('username')
      .isLength({ min: 3, max: 20 })
      .isAlphanumeric()
      .withMessage('Username must be 3-20 alphanumeric characters')
  ],
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
  registerUser
);

6. HTTPS Enforcement

In production, all communication must happen over HTTPS:

// Force HTTPS in production
if (process.env.NODE_ENV === 'production') {
  app.use((req, res, next) => {
    if (req.header('x-forwarded-proto') !== 'https') {
      res.redirect(`https://${req.header('host')}${req.url}`);
    } else {
      next();
    }
  });
}

7. Security Headers

Using Helmet.js to set security-related HTTP headers:

const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      scriptSrc: ["'self'"],
      imgSrc: ["'self'", "data:", "https:"]
    }
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

8. CORS Configuration

Properly configured CORS prevents unauthorized cross-origin requests:

const cors = require('cors');

const corsOptions = {
  origin: process.env.ALLOWED_ORIGINS.split(','),
  credentials: true, // allow cookies
  optionsSuccessStatus: 200
};

app.use(cors(corsOptions));

11. Future Improvements and Enhancements

While the current system is production-ready, there are several enhancements I'm considering for future iterations:

OAuth Integration (Social Login)

Adding OAuth providers like Google, GitHub, and Microsoft would improve user experience:

  • Benefits: Faster registration, no password management for users, leverages trusted identity providers
  • Implementation: Use Passport.js with OAuth strategies
  • Challenge: Mapping OAuth profiles to internal user structure, handling account linking

Two-Factor Authentication (2FA)

Adding a second authentication factor significantly increases account security:

  • TOTP (Time-Based One-Time Password): Using apps like Google Authenticator or Authy
  • SMS Verification: Less secure but more accessible
  • Backup Codes: Essential for account recovery
  • Implementation: Libraries like speakeasy for TOTP generation and verification

Session and Device Management

Allowing users to view and manage their active sessions:

  • List all active refresh tokens with device information
  • Remote logout from specific devices
  • Notifications for new login locations
  • Suspicious activity detection based on login patterns

Account Lockout Mechanisms

Temporary account locks after repeated failed login attempts:

  • Lock account after 10 failed attempts in 24 hours
  • Email notification to account owner
  • Unlock after time period or email verification
  • Prevents distributed brute-force attacks

Passwordless Authentication

Magic links or WebAuthn for password-free login:

  • Magic Links: Email-based one-time login links
  • WebAuthn: Biometric authentication (fingerprint, Face ID)
  • Benefits: No password to steal, better UX, modern security standard

Advanced Monitoring and Alerting

Real-time security monitoring:

  • Unusual login patterns (time, location, device)
  • Multiple failed login attempts from same IP
  • Token theft detection (same token used from different IPs)
  • Integration with security information and event management (SIEM) tools

Refresh Token Families

Enhanced token rotation using refresh token families:

  • Track entire lineage of refresh tokens
  • Detect and revoke entire families if one token is reused
  • Provides stronger protection against token replay attacks

12. Conclusion

Authentication Is a Security System, Not Just a Feature

Throughout this journey, one principle has guided every decision: authentication is your application's security foundation. Everything else—user data, business logic, financial transactions—depends on authentication working correctly.

This isn't just about preventing unauthorized access. It's about:

  • Protecting user privacy and data
  • Maintaining business reputation and trust
  • Complying with security regulations and standards
  • Preventing financial and legal liabilities
  • Ensuring system integrity and availability

Key Lessons Learned

1. Security and usability are not mutually exclusive.

Short-lived access tokens with refresh token rotation provide both strong security and seamless user experience. Users stay logged in for days without ever noticing the token refresh happening behind the scenes.

2. Never trust the client.

Every security decision must be validated server-side. Client-side checks are for UX only. JWTs provide tamper-proof data transmission, but only if properly verified.

3. Defense in depth is essential.

No single security measure is perfect. The combination of short-lived tokens, rate limiting, input validation, secure password hashing, and proper error handling creates overlapping layers of protection.

4. Generic errors protect against enumeration.

Every piece of information leaked to potential attackers makes their job easier. Consistent, generic error messages prevent account enumeration and slow down attacks.

5. Cryptographic security requires proper primitives.

Using bcrypt for password hashing and crypto.randomBytes for token generation ensures cryptographic security. Never roll your own crypto or use weak alternatives.

Building Secure Systems Is an Ongoing Process

Security is not a one-time implementation. It requires:

  • Regular security audits and penetration testing
  • Staying informed about new vulnerabilities and attack vectors
  • Updating dependencies and rotating secrets
  • Monitoring authentication patterns and suspicious activity
  • Learning from security incidents in the wider ecosystem

Final Thoughts

Designing this authentication system taught me that security engineering is as much about understanding human behavior and attack patterns as it is about code. The best technical solution means nothing if users choose weak passwords or store tokens insecurely.

The authentication system I've built here is production-ready, but it's not perfect. No system is. Security is about reducing risk to acceptable levels, not eliminating it entirely. What makes a system "secure enough" depends on your threat model—what you're protecting and who you're protecting it from.

For a standard web application with user accounts, this system provides strong baseline security. For applications handling sensitive financial data or healthcare information, additional measures like 2FA and hardware security keys would be necessary.

Remember: Authentication security is not a feature you ship and forget. It's a commitment to your users that you take their security seriously, and it requires ongoing attention, updates, and vigilance.

"Security is always excessive until it's not enough." — Robbie Sinclair

I hope this detailed walkthrough has been valuable. Building secure authentication systems is challenging, but it's one of the most important responsibilities we have as backend developers. Our users trust us with their data and digital identities—we owe them systems that respect and protect that trust.

Stay secure, keep learning, and never stop questioning your assumptions about security.