Building Secure Web Authentication - Part 4: Advanced Features

Our basic authentication system works, but production applications need more sophisticated features. What if users want to stay logged in for weeks? How do we handle multiple users with a real database? How do we refresh expired tokens without forcing users to log in again?

In this part, we'll implement the advanced features that make authentication production-ready.

What We're Building

Advanced authentication features:

  • ✅ Remember Me functionality (long-lived sessions)
  • ✅ Multi-user support with PostgreSQL
  • ✅ Token refresh mechanism
  • ✅ Protected content sections
  • ✅ User session management

Time estimate: 60-90 minutes

Feature 1: Remember Me

The "Remember Me" checkbox lets users stay logged in for extended periods (weeks or months) instead of just days.

The Challenge

We have two conflicting goals:

  1. Security: Short-lived tokens (e.g., 15 minutes) are more secure
  2. UX: Users don't want to log in constantly

Solution: Use two tokens:

  • Access Token: Short-lived (15 minutes), used for API requests
  • Refresh Token: Long-lived (30 days), used to get new access tokens

Architecture

User logs in
    ↓
Generate two tokens:
  - Access Token (15 min)
  - Refresh Token (30 days)
    ↓
Store refresh token in database
    ↓
Send both to client as httpOnly cookies
    ↓
─────────────────────────────────
Access token expires after 15 min
    ↓
Client requests new access token
    ↓
Server verifies refresh token
    ↓
Generate new access token (15 min)
    ↓
Send to client

Implementation

Update src/utils/jwt.ts:

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

export interface JWTPayload {
  userId: string;
  username: string;
  type: 'access' | 'refresh'; // Add token type
}

export interface DecodedToken extends JWTPayload {
  iat: number;
  exp: number;
}

/**
 * Generate access token (short-lived)
 */
export function generateAccessToken(userId: string, username: string): string {
  return jwt.sign(
    { userId, username, type: 'access' },
    config.jwt.secret,
    { expiresIn: '15m' } // 15 minutes
  );
}

/**
 * Generate refresh token (long-lived)
 */
export function generateRefreshToken(userId: string, username: string): string {
  return jwt.sign(
    { userId, username, type: 'refresh' },
    config.jwt.secret,
    { expiresIn: '30d' } // 30 days
  );
}

/**
 * Verify token and check type
 */
export function verifyToken(
  token: string,
  expectedType?: 'access' | 'refresh'
): DecodedToken | null {
  try {
    const decoded = jwt.verify(token, config.jwt.secret) as DecodedToken;

    // Check token type if specified
    if (expectedType && decoded.type !== expectedType) {
      return null;
    }

    return decoded;
  } catch {
    return null;
  }
}

Database Schema for Refresh Tokens

Create src/database/schema.sql:

-- Users table
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(255) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Refresh tokens table
CREATE TABLE refresh_tokens (
  id SERIAL PRIMARY KEY,
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  token_hash VARCHAR(255) NOT NULL, -- Store hash, not plain token
  expires_at TIMESTAMP NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  revoked_at TIMESTAMP NULL, -- For manual revocation
  ip_address VARCHAR(45), -- Track where token was created
  user_agent TEXT -- Track device
);

-- Index for fast lookups
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);

Database Connection

Create src/database/connection.ts:

import { Pool } from 'pg';
import { config } from '../config/env';

// Create PostgreSQL connection pool
export const pool = new Pool({
  host: config.database.host,
  port: config.database.port,
  database: config.database.name,
  user: config.database.user,
  password: config.database.password,
  max: 20, // Maximum connections in pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Test connection on startup
pool.on('connect', () => {
  console.log('✅ Database connected');
});

pool.on('error', (err) => {
  console.error('❌ Unexpected database error:', err);
  process.exit(-1);
});

// Helper function for queries
export async function query(text: string, params?: any[]) {
  const start = Date.now();
  const result = await pool.query(text, params);
  const duration = Date.now() - start;

  console.log('Executed query', { text, duration, rows: result.rowCount });
  return result;
}

Add to .env:

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=auth_system
DATABASE_USER=postgres
DATABASE_PASSWORD=your_password

User Model

Create src/models/User.ts:

import { query } from '../database/connection';
import { hashPassword, verifyPassword } from '../utils/password';
import crypto from 'crypto';

export interface User {
  id: number;
  username: string;
  email: string;
  created_at: Date;
}

export interface UserWithPassword extends User {
  password_hash: string;
}

/**
 * Create a new user
 */
export async function createUser(
  username: string,
  email: string,
  password: string
): Promise<User> {
  const passwordHash = await hashPassword(password);

  const result = await query(
    `INSERT INTO users (username, email, password_hash)
     VALUES ($1, $2, $3)
     RETURNING id, username, email, created_at`,
    [username, email, passwordHash]
  );

  return result.rows[0];
}

/**
 * Find user by username
 */
export async function findUserByUsername(
  username: string
): Promise<UserWithPassword | null> {
  const result = await query(
    `SELECT id, username, email, password_hash, created_at
     FROM users
     WHERE username = $1`,
    [username]
  );

  return result.rows[0] || null;
}

/**
 * Verify user credentials
 */
export async function verifyUserCredentials(
  username: string,
  password: string
): Promise<User | null> {
  const user = await findUserByUsername(username);

  if (!user) {
    return null;
  }

  const isValid = await verifyPassword(password, user.password_hash);

  if (!isValid) {
    return null;
  }

  // Return user without password hash
  const { password_hash, ...userWithoutPassword } = user;
  return userWithoutPassword;
}

/**
 * Store refresh token
 */
export async function storeRefreshToken(
  userId: number,
  token: string,
  expiresAt: Date,
  ipAddress?: string,
  userAgent?: string
): Promise<void> {
  // Hash the token before storing (don't store plain token)
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  await query(
    `INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent)
     VALUES ($1, $2, $3, $4, $5)`,
    [userId, tokenHash, expiresAt, ipAddress, userAgent]
  );
}

/**
 * Verify refresh token exists and is valid
 */
export async function verifyRefreshToken(token: string): Promise<boolean> {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  const result = await query(
    `SELECT id FROM refresh_tokens
     WHERE token_hash = $1
       AND expires_at > NOW()
       AND revoked_at IS NULL`,
    [tokenHash]
  );

  return result.rowCount > 0;
}

/**
 * Revoke refresh token (logout)
 */
export async function revokeRefreshToken(token: string): Promise<void> {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  await query(
    `UPDATE refresh_tokens
     SET revoked_at = NOW()
     WHERE token_hash = $1`,
    [tokenHash]
  );
}

/**
 * Revoke all user's refresh tokens (logout all devices)
 */
export async function revokeAllUserTokens(userId: number): Promise<void> {
  await query(
    `UPDATE refresh_tokens
     SET revoked_at = NOW()
     WHERE user_id = $1 AND revoked_at IS NULL`,
    [userId]
  );
}

/**
 * Clean up expired tokens (run periodically)
 */
export async function cleanupExpiredTokens(): Promise<number> {
  const result = await query(
    `DELETE FROM refresh_tokens
     WHERE expires_at < NOW() OR revoked_at IS NOT NULL`,
    []
  );

  return result.rowCount || 0;
}

Updated Login Route

Update src/routes/auth.ts:

import express, { Request, Response } from 'express';
import { verifyUserCredentials, storeRefreshToken } from '../models/User';
import { generateAccessToken, generateRefreshToken } from '../utils/jwt';

const router = express.Router();

/**
 * POST /api/auth/login
 * Login with Remember Me support
 */
router.post('/login', async (req: Request, res: Response) => {
  try {
    const { username, password, rememberMe } = req.body;

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

    // Verify credentials against database
    const user = await verifyUserCredentials(username, password);

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

    // Generate tokens
    const accessToken = generateAccessToken(
      user.id.toString(),
      user.username
    );
    const refreshToken = generateRefreshToken(
      user.id.toString(),
      user.username
    );

    // Store refresh token in database
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 30); // 30 days

    await storeRefreshToken(
      user.id,
      refreshToken,
      expiresAt,
      req.ip,
      req.get('user-agent')
    );

    // Set cookies
    res.cookie('access_token', accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000, // 15 minutes
    });

    if (rememberMe) {
      res.cookie('refresh_token', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      });
    }

    return res.json({
      success: true,
      user: {
        id: user.id,
        username: user.username,
        email: user.email,
      },
    });
  } catch (error) {
    console.error('Login error:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});

/**
 * POST /api/auth/refresh
 * Get new access token using refresh token
 */
router.post('/refresh', async (req: Request, res: Response) => {
  try {
    const refreshToken = req.cookies.refresh_token;

    if (!refreshToken) {
      return res.status(401).json({
        error: 'No refresh token provided',
      });
    }

    // Verify refresh token JWT
    const decoded = verifyToken(refreshToken, 'refresh');

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

    // Verify token exists in database and isn't revoked
    const isValid = await verifyRefreshToken(refreshToken);

    if (!isValid) {
      return res.status(401).json({
        error: 'Refresh token revoked or expired',
      });
    }

    // Generate new access token
    const newAccessToken = generateAccessToken(
      decoded.userId,
      decoded.username
    );

    // Set new access token cookie
    res.cookie('access_token', newAccessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 15 * 60 * 1000,
    });

    return res.json({
      success: true,
    });
  } catch (error) {
    console.error('Refresh token error:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
});

export default router;

Frontend: Automatic Token Refresh

// api/auth.ts
let refreshPromise: Promise<void> | null = null;

export async function refreshAccessToken() {
  // Prevent multiple simultaneous refresh requests
  if (refreshPromise) {
    return refreshPromise;
  }

  refreshPromise = fetch('http://localhost:3000/api/auth/refresh', {
    method: 'POST',
    credentials: 'include',
  })
    .then((res) => {
      if (!res.ok) {
        throw new Error('Refresh failed');
      }
    })
    .finally(() => {
      refreshPromise = null;
    });

  return refreshPromise;
}

export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  try {
    // Try request with current token
    const response = await fetch(url, {
      ...options,
      credentials: 'include',
    });

    // If unauthorized, try refreshing token
    if (response.status === 401) {
      await refreshAccessToken();

      // Retry original request
      return fetch(url, {
        ...options,
        credentials: 'include',
      });
    }

    return response;
  } catch (error) {
    // Refresh failed, redirect to login
    window.location.href = '/login';
    throw error;
  }
}

// Usage
const response = await fetchWithAuth('http://localhost:3000/api/protected/profile');
const data = await response.json();

Feature 2: Protected Content Sections

Let's implement a system where content can be marked as public, private, or draft.

Content Model

Create src/models/Content.ts:

import { query } from '../database/connection';

export type ContentVisibility = 'public' | 'private' | 'draft';

export interface Content {
  id: number;
  title: string;
  slug: string;
  content: string;
  visibility: ContentVisibility;
  author_id: number;
  created_at: Date;
  updated_at: Date;
}

/**
 * Get content by slug
 * Respects visibility - only returns if user has access
 */
export async function getContentBySlug(
  slug: string,
  userId?: number
): Promise<Content | null> {
  let query_string = `
    SELECT * FROM content
    WHERE slug = $1
  `;
  const params: any[] = [slug];

  if (!userId) {
    // Not authenticated - only show public content
    query_string += ` AND visibility = 'public'`;
  } else {
    // Authenticated - show public + own drafts/private
    query_string += `
      AND (
        visibility = 'public'
        OR (author_id = $2 AND visibility IN ('private', 'draft'))
      )
    `;
    params.push(userId);
  }

  const result = await query(query_string, params);
  return result.rows[0] || null;
}

/**
 * Get all content
 * Filters based on user authentication
 */
export async function getAllContent(
  userId?: number,
  visibility?: ContentVisibility
): Promise<Content[]> {
  let query_string = 'SELECT * FROM content WHERE 1=1';
  const params: any[] = [];
  let paramIndex = 1;

  if (!userId) {
    // Not authenticated - only public
    query_string += ` AND visibility = 'public'`;
  } else {
    if (visibility) {
      // Show specific visibility level (if user owns it or it's public)
      query_string += `
        AND (
          (visibility = $${paramIndex} AND author_id = $${paramIndex + 1})
          OR visibility = 'public'
        )
      `;
      params.push(visibility, userId);
      paramIndex += 2;
    } else {
      // Show all content user has access to
      query_string += `
        AND (
          visibility = 'public'
          OR author_id = $${paramIndex}
        )
      `;
      params.push(userId);
      paramIndex++;
    }
  }

  query_string += ' ORDER BY created_at DESC';

  const result = await query(query_string, params);
  return result.rows;
}

/**
 * Create content
 */
export async function createContent(
  title: string,
  slug: string,
  content: string,
  visibility: ContentVisibility,
  authorId: number
): Promise<Content> {
  const result = await query(
    `INSERT INTO content (title, slug, content, visibility, author_id)
     VALUES ($1, $2, $3, $4, $5)
     RETURNING *`,
    [title, slug, content, visibility, authorId]
  );

  return result.rows[0];
}

/**
 * Update content visibility (e.g., publish draft)
 */
export async function updateContentVisibility(
  id: number,
  visibility: ContentVisibility,
  userId: number
): Promise<boolean> {
  const result = await query(
    `UPDATE content
     SET visibility = $1, updated_at = NOW()
     WHERE id = $2 AND author_id = $3`,
    [visibility, id, userId]
  );

  return (result.rowCount || 0) > 0;
}

Content Routes

Create src/routes/content.ts:

import express, { Request, Response } from 'express';
import { requireAuth, optionalAuth } from '../middleware/auth';
import {
  getAllContent,
  getContentBySlug,
  createContent,
  updateContentVisibility,
} from '../models/Content';

const router = express.Router();

/**
 * GET /api/content
 * List all content (respects visibility and authentication)
 */
router.get('/', optionalAuth, async (req: Request, res: Response) => {
  const userId = req.user ? parseInt(req.user.userId) : undefined;
  const visibility = req.query.visibility as 'public' | 'private' | 'draft';

  const content = await getAllContent(userId, visibility);

  res.json({
    success: true,
    content,
    count: content.length,
  });
});

/**
 * GET /api/content/:slug
 * Get single content by slug
 */
router.get('/:slug', optionalAuth, async (req: Request, res: Response) => {
  const { slug } = req.params;
  const userId = req.user ? parseInt(req.user.userId) : undefined;

  const content = await getContentBySlug(slug, userId);

  if (!content) {
    return res.status(404).json({
      error: 'Content not found or access denied',
    });
  }

  res.json({
    success: true,
    content,
  });
});

/**
 * POST /api/content
 * Create new content (requires auth)
 */
router.post('/', requireAuth, async (req: Request, res: Response) => {
  const { title, slug, content, visibility } = req.body;
  const authorId = parseInt(req.user!.userId);

  const newContent = await createContent(
    title,
    slug,
    content,
    visibility || 'draft',
    authorId
  );

  res.status(201).json({
    success: true,
    content: newContent,
  });
});

/**
 * PATCH /api/content/:id/visibility
 * Update content visibility (e.g., publish draft)
 */
router.patch(
  '/:id/visibility',
  requireAuth,
  async (req: Request, res: Response) => {
    const { id } = req.params;
    const { visibility } = req.body;
    const userId = parseInt(req.user!.userId);

    const updated = await updateContentVisibility(
      parseInt(id),
      visibility,
      userId
    );

    if (!updated) {
      return res.status(404).json({
        error: 'Content not found or unauthorized',
      });
    }

    res.json({
      success: true,
      message: `Content visibility updated to ${visibility}`,
    });
  }
);

export default router;

Feature 3: Session Management

Let users see and manage their active sessions.

// src/routes/sessions.ts
import express, { Request, Response } from 'express';
import { requireAuth } from '../middleware/auth';
import { query } from '../database/connection';

const router = express.Router();

/**
 * GET /api/sessions
 * List user's active sessions
 */
router.get('/', requireAuth, async (req: Request, res: Response) => {
  const userId = parseInt(req.user!.userId);

  const result = await query(
    `SELECT
       id,
       ip_address,
       user_agent,
       created_at,
       expires_at,
       (token_hash = $2) as is_current
     FROM refresh_tokens
     WHERE user_id = $1
       AND expires_at > NOW()
       AND revoked_at IS NULL
     ORDER BY created_at DESC`,
    [userId, getCurrentTokenHash(req)]
  );

  res.json({
    success: true,
    sessions: result.rows,
  });
});

/**
 * DELETE /api/sessions/:id
 * Revoke specific session
 */
router.delete('/:id', requireAuth, async (req: Request, res: Response) => {
  const userId = parseInt(req.user!.userId);
  const sessionId = parseInt(req.params.id);

  const result = await query(
    `UPDATE refresh_tokens
     SET revoked_at = NOW()
     WHERE id = $1 AND user_id = $2`,
    [sessionId, userId]
  );

  if (result.rowCount === 0) {
    return res.status(404).json({
      error: 'Session not found',
    });
  }

  res.json({
    success: true,
    message: 'Session revoked',
  });
});

/**
 * DELETE /api/sessions
 * Revoke all sessions except current
 */
router.delete('/', requireAuth, async (req: Request, res: Response) => {
  const userId = parseInt(req.user!.userId);
  const currentTokenHash = getCurrentTokenHash(req);

  await query(
    `UPDATE refresh_tokens
     SET revoked_at = NOW()
     WHERE user_id = $1
       AND token_hash != $2
       AND revoked_at IS NULL`,
    [userId, currentTokenHash]
  );

  res.json({
    success: true,
    message: 'All other sessions revoked',
  });
});

function getCurrentTokenHash(req: Request): string {
  const token = req.cookies.refresh_token;
  return crypto.createHash('sha256').update(token).digest('hex');
}

export default router;

Summary

You've learned:

  • Remember Me - Access + refresh token pattern for long-lived sessions
  • Database integration - PostgreSQL with users and refresh tokens
  • Token refresh - Automatic token renewal on the frontend
  • Protected content - Public, private, and draft visibility levels
  • Session management - View and revoke active sessions

What's Next?

In Part 5, we'll cover security and production deployment:

  • Common vulnerabilities and how to prevent them
  • Social login with OAuth (Google, GitHub)
  • Rate limiting and brute force protection
  • Security headers and HTTPS
  • Production deployment checklist

Continue the series: Part 5: Security & Production →

Questions? These features add significant complexity. Test thoroughly before moving to production.