Building Secure Web Authentication - Part 2: JWT Implementation

In Part 1, we learned why JWT tokens with httpOnly cookies are a great choice for modern web applications. Now it's time to build it.

By the end of this tutorial, you'll have working login and logout endpoints with secure password hashing, JWT token generation, and httpOnly cookie management. Let's get our hands dirty with code!

What We're Building

A complete authentication API with these endpoints:

POST /api/auth/login    - Log in with username/password
POST /api/auth/logout   - Log out (clear cookie)
GET  /api/auth/check    - Check if user is authenticated

Features:

  • ✅ Secure password hashing with bcrypt
  • ✅ JWT token generation and verification
  • ✅ httpOnly cookies for XSS protection
  • ✅ TypeScript for type safety
  • ✅ Environment variable configuration

Time estimate: 45-60 minutes

Project Setup

Let's start from scratch. Create a new directory and initialize the project:

mkdir auth-system
cd auth-system
npm init -y

Install Dependencies

# Core dependencies
npm install express jsonwebtoken bcryptjs cookie-parser dotenv

# TypeScript and types
npm install -D typescript @types/node @types/express @types/jsonwebtoken @types/bcryptjs @types/cookie-parser

# Development tools
npm install -D ts-node nodemon

What each package does:

  • express - Web server framework
  • jsonwebtoken - Create and verify JWT tokens
  • bcryptjs - Hash passwords securely
  • cookie-parser - Parse cookie headers
  • dotenv - Load environment variables from .env file

TypeScript Configuration

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Package Scripts

Update package.json scripts:

{
  "scripts": {
    "dev": "nodemon --exec ts-node src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

Environment Variables

Create .env file:

# Server configuration
PORT=3000
NODE_ENV=development

# JWT configuration
JWT_SECRET=your-super-secret-key-change-this-in-production-min-32-chars
JWT_EXPIRES_IN=7d

# For demo purposes (in production, use a database)
DEMO_USERNAME=admin
DEMO_PASSWORD=changeme123

⚠️ Security Note: The JWT_SECRET should be a long, random string. Generate one with:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Create .env.example for reference (commit this to git):

PORT=3000
NODE_ENV=development
JWT_SECRET=generate-a-random-secret-key
JWT_EXPIRES_IN=7d
DEMO_USERNAME=admin
DEMO_PASSWORD=your-secure-password

Don't forget .gitignore:

node_modules/
dist/
.env

Project Structure

Create this folder structure:

auth-system/
├── src/
│   ├── server.ts           # Express server setup
│   ├── config/
│   │   └── env.ts          # Environment variables
│   ├── utils/
│   │   ├── jwt.ts          # JWT utilities
│   │   └── password.ts     # Password hashing utilities
│   └── routes/
│       └── auth.ts         # Authentication routes
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.json

Step 1: Environment Configuration

Create src/config/env.ts:

import dotenv from 'dotenv';

// Load environment variables from .env file
dotenv.config();

// Validate required environment variables
const requiredEnvVars = ['JWT_SECRET', 'DEMO_USERNAME', 'DEMO_PASSWORD'];

for (const envVar of requiredEnvVars) {
  if (!process.env[envVar]) {
    throw new Error(`Missing required environment variable: ${envVar}`);
  }
}

// Export typed environment configuration
export const config = {
  port: parseInt(process.env.PORT || '3000', 10),
  nodeEnv: process.env.NODE_ENV || 'development',
  jwt: {
    secret: process.env.JWT_SECRET!,
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
  },
  demo: {
    username: process.env.DEMO_USERNAME!,
    password: process.env.DEMO_PASSWORD!,
  },
} as const;

Why this matters:

  • Catches missing environment variables at startup (fail fast)
  • Provides type-safe access to configuration
  • Centralizes all environment variable access

Step 2: Password Hashing Utilities

Create src/utils/password.ts:

import bcrypt from 'bcryptjs';

/**
 * Hash a plain text password
 * Uses bcrypt with 10 rounds (good balance of security and performance)
 */
export async function hashPassword(password: string): Promise<string> {
  const saltRounds = 10;
  return bcrypt.hash(password, saltRounds);
}

/**
 * Compare a plain text password with a hashed password
 * Returns true if they match, false otherwise
 */
export async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  return bcrypt.compare(password, hashedPassword);
}

Understanding bcrypt:

Bcrypt is designed to be slow (intentionally). This makes brute force attacks much more difficult:

// Fast hash (BAD):
// Attacker can try 1,000,000 passwords per second
const badHash = require('crypto')
  .createHash('sha256')
  .update(password)
  .digest('hex');

// Bcrypt (GOOD):
// Attacker can only try ~10 passwords per second
const goodHash = await bcrypt.hash(password, 10);

The saltRounds parameter (10) determines how slow:

  • 10 rounds = ~100ms (good for most applications)
  • 12 rounds = ~400ms (high security)
  • 8 rounds = ~40ms (legacy systems)

Why salt? Each hash includes a random "salt":

Password: "password123"
Salt 1:   "a4c8f2..."
Hash 1:   "$2a$10$a4c8f2..."

Same password, different salt:
Salt 2:   "9x3k7m..."
Hash 2:   "$2a$10$9x3k7m..."

This prevents rainbow table attacks (pre-computed hash databases).

Step 3: JWT Utilities

Create src/utils/jwt.ts:

import jwt from 'jsonwebtoken';
import { config } from '../config/env';

// Define the shape of our JWT payload
export interface JWTPayload {
  userId: string;
  username: string;
}

// Define the shape of the decoded token (includes JWT metadata)
export interface DecodedToken extends JWTPayload {
  iat: number; // Issued at (timestamp)
  exp: number; // Expires at (timestamp)
}

/**
 * Generate a JWT token
 * Signs the payload with our secret key
 */
export function generateToken(payload: JWTPayload): string {
  return jwt.sign(payload, config.jwt.secret, {
    expiresIn: config.jwt.expiresIn,
  });
}

/**
 * Verify and decode a JWT token
 * Returns the payload if valid, null if invalid or expired
 */
export function verifyToken(token: string): DecodedToken | null {
  try {
    const decoded = jwt.verify(token, config.jwt.secret) as DecodedToken;
    return decoded;
  } catch (error) {
    // Token is invalid or expired
    return null;
  }
}

/**
 * Decode a JWT without verification (for debugging only)
 * DO NOT use this for authentication!
 */
export function decodeToken(token: string): DecodedToken | null {
  try {
    return jwt.decode(token) as DecodedToken;
  } catch {
    return null;
  }
}

Understanding JWT Structure:

A JWT consists of three parts separated by dots:

eyJhbGc... . eyJ1c2Vy... . SflKxwRJ...
   ↑            ↑            ↑
 Header      Payload      Signature

Let's decode each part:

// Header (algorithm and type)
{
  "alg": "HS256",    // Algorithm: HMAC SHA-256
  "typ": "JWT"       // Type: JSON Web Token
}

// Payload (your data)
{
  "userId": "123",
  "username": "alice",
  "iat": 1699564800,  // Issued at (Unix timestamp)
  "exp": 1700169600   // Expires at (Unix timestamp)
}

// Signature (proves authenticity)
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)

Key points:

  • The signature prevents tampering - if someone modifies the payload, the signature won't match
  • The payload is not encrypted - anyone can decode it (don't store secrets!)
  • Only the server with the secret key can create valid tokens

Step 4: Authentication Routes

Create src/routes/auth.ts:

import express, { Request, Response } from 'express';
import { config } from '../config/env';
import { verifyPassword } from '../utils/password';
import { generateToken, verifyToken } from '../utils/jwt';

const router = express.Router();

// In production, this would come from a database
// For demo purposes, we'll use environment variables
const demoUser = {
  id: '1',
  username: config.demo.username,
  password: config.demo.password, // In production: hashed password from DB
};

/**
 * POST /api/auth/login
 * Authenticate user and return JWT in httpOnly cookie
 */
router.post('/login', async (req: Request, res: Response) => {
  try {
    const { username, password } = req.body;

    // Validate input
    if (!username || !password) {
      return res.status(400).json({
        error: 'Username and password are required',
      });
    }

    // Check if user exists (in production: query database)
    if (username !== demoUser.username) {
      return res.status(401).json({
        error: 'Invalid credentials',
      });
    }

    // Verify password
    // Note: In production, compare with hashed password from database
    // const isValid = await verifyPassword(password, demoUser.hashedPassword);
    const isValid = password === demoUser.password;

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

    // Generate JWT token
    const token = generateToken({
      userId: demoUser.id,
      username: demoUser.username,
    });

    // Set httpOnly cookie
    res.cookie('auth_token', token, {
      httpOnly: true,  // Cannot be accessed by JavaScript
      secure: config.nodeEnv === 'production', // Only sent over HTTPS in production
      sameSite: 'strict', // CSRF protection
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
    });

    // Return success (don't send token in response body)
    return res.status(200).json({
      success: true,
      user: {
        id: demoUser.id,
        username: demoUser.username,
      },
    });
  } catch (error) {
    console.error('Login error:', error);
    return res.status(500).json({
      error: 'Internal server error',
    });
  }
});

/**
 * POST /api/auth/logout
 * Clear the authentication cookie
 */
router.post('/logout', (req: Request, res: Response) => {
  // Clear the auth cookie
  res.clearCookie('auth_token', {
    httpOnly: true,
    secure: config.nodeEnv === 'production',
    sameSite: 'strict',
  });

  return res.status(200).json({
    success: true,
    message: 'Logged out successfully',
  });
});

/**
 * GET /api/auth/check
 * Check if user is authenticated
 */
router.get('/check', (req: Request, res: Response) => {
  // Get token from cookie
  const token = req.cookies.auth_token;

  if (!token) {
    return res.status(401).json({
      authenticated: false,
    });
  }

  // Verify token
  const decoded = verifyToken(token);

  if (!decoded) {
    // Token is invalid or expired
    res.clearCookie('auth_token');
    return res.status(401).json({
      authenticated: false,
    });
  }

  // Token is valid
  return res.status(200).json({
    authenticated: true,
    user: {
      id: decoded.userId,
      username: decoded.username,
    },
  });
});

export default router;

Understanding Cookie Flags:

res.cookie('auth_token', token, {
  httpOnly: true,    // JavaScript cannot access (XSS protection)
  secure: true,      // Only sent over HTTPS (MITM protection)
  sameSite: 'strict', // Not sent on cross-site requests (CSRF protection)
  maxAge: 604800000, // Expiration time in milliseconds
});

Cookie attribute comparison:

Attribute Value Effect
httpOnly true Protects against XSS - JavaScript cannot read cookie
httpOnly false ❌ Cookie accessible via document.cookie
secure true Only sent over HTTPS (encrypted)
secure false Sent over HTTP (unencrypted) - ❌ vulnerable to sniffing
sameSite 'strict' Never sent on cross-site requests
sameSite 'lax' Sent on top-level navigation (e.g., clicking a link)
sameSite 'none' ❌ Sent on all requests (CSRF vulnerable, requires secure)

Step 5: Server Setup

Create src/server.ts:

import express from 'express';
import cookieParser from 'cookie-parser';
import { config } from './config/env';
import authRoutes from './routes/auth';

const app = express();

// Middleware
app.use(express.json()); // Parse JSON request bodies
app.use(cookieParser()); // Parse cookies from Cookie header

// Routes
app.use('/api/auth', authRoutes);

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Start server
app.listen(config.port, () => {
  console.log(`🚀 Server running on http://localhost:${config.port}`);
  console.log(`📝 Environment: ${config.nodeEnv}`);
  console.log(`🔐 JWT expiration: ${config.jwt.expiresIn}`);
});

Testing the Authentication

Start the development server:

npm run dev

You should see:

🚀 Server running on http://localhost:3000
📝 Environment: development
🔐 JWT expiration: 7d

Test 1: Login

curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"changeme123"}' \
  -c cookies.txt \
  -v

Expected response:

{
  "success": true,
  "user": {
    "id": "1",
    "username": "admin"
  }
}

Check the Set-Cookie header:

Set-Cookie: auth_token=eyJhbGc...; Path=/; HttpOnly; SameSite=Strict

Test 2: Check Authentication

curl http://localhost:3000/api/auth/check \
  -b cookies.txt

Expected response:

{
  "authenticated": true,
  "user": {
    "id": "1",
    "username": "admin"
  }
}

Test 3: Logout

curl -X POST http://localhost:3000/api/auth/logout \
  -b cookies.txt \
  -c cookies.txt

Expected response:

{
  "success": true,
  "message": "Logged out successfully"
}

Test 4: Check Authentication (After Logout)

curl http://localhost:3000/api/auth/check \
  -b cookies.txt

Expected response:

{
  "authenticated": false
}

Common Issues and Solutions

Issue 1: "Missing required environment variable"

Symptoms: Server crashes on startup

Cause: .env file missing or incomplete

Solution:

# Copy the example file
cp .env.example .env

# Edit with your values
nano .env

Issue 2: Cookies not being set

Symptoms: Login succeeds but /check shows not authenticated

Cause: Missing cookie-parser middleware or incorrect cookie options

Solution:

// Make sure this is in server.ts
app.use(cookieParser());

// Make sure sameSite is compatible with your setup
sameSite: 'lax' // Try 'lax' instead of 'strict' for testing

Issue 3: "Invalid token" immediately after login

Symptoms: Token is rejected even though just generated

Cause: Server time is incorrect or JWT_SECRET changed

Solution:

# Check server time
date

# Make sure JWT_SECRET hasn't changed
echo $JWT_SECRET

Security Best Practices

✅ DO:

  1. Use environment variables for secrets

    const secret = process.env.JWT_SECRET; // ✅ Good
    
  2. Use httpOnly cookies

    res.cookie('token', jwt, { httpOnly: true }); // ✅ Good
    
  3. Use HTTPS in production

    secure: process.env.NODE_ENV === 'production' // ✅ Good
    
  4. Validate all user input

    if (!username || !password) { /* error */ } // ✅ Good
    
  5. Use bcrypt for passwords

    await bcrypt.hash(password, 10); // ✅ Good
    

❌ DON'T:

  1. Store tokens in localStorage

    localStorage.setItem('token', jwt); // ❌ Vulnerable to XSS
    
  2. Send tokens in response body

    res.json({ token: jwt }); // ❌ Should be in httpOnly cookie
    
  3. Use weak secrets

    const secret = 'secret'; // ❌ Too short, predictable
    
  4. Store plain text passwords

    db.users.create({ password: 'plain' }); // ❌ Always hash!
    
  5. Ignore token expiration

    jwt.sign(payload, secret); // ❌ Missing expiresIn
    

What We've Built

You now have a working JWT authentication system with:

  • ✅ Secure password comparison (ready for bcrypt in production)
  • ✅ JWT token generation with expiration
  • ✅ httpOnly cookies for XSS protection
  • ✅ Login, logout, and auth check endpoints
  • ✅ Type-safe TypeScript implementation
  • ✅ Environment variable configuration

What's Next?

In Part 3, we'll add middleware to protect routes:

  • Creating authentication middleware
  • Protecting API endpoints
  • Handling unauthorized access
  • Extracting user info from tokens
  • Frontend integration examples

Summary

You've learned:

  • Project setup - TypeScript, Express, environment configuration
  • Password hashing - Using bcrypt for secure password storage
  • JWT tokens - Generation, verification, and payload structure
  • httpOnly cookies - Secure cookie flags and CSRF protection
  • Authentication endpoints - Login, logout, and auth check
  • Security best practices - What to do and what to avoid

Resources


Continue the series: Part 3: Route Protection & Middleware →

Questions? The authentication endpoints are the foundation of your security. Make sure you understand each piece before continuing.