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:
- Security: Short-lived tokens (e.g., 15 minutes) are more secure
- 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.