Implementing User Authentication with JWT and TypeScript
Complete guide to building secure JWT-based authentication for Node.js APIs using TypeScript with refresh tokens, password hashing, and best practices.
Implementing User Authentication with JWT and TypeScript
JWT (JSON Web Tokens) provide a scalable, stateless authentication mechanism perfect for modern APIs. This guide covers complete implementation with refresh tokens, password hashing, and TypeScript type safety.
Why JWT?
JWT enables stateless authentication without server-side session storage. Tokens are self-contained, signed cryptographically, and can be verified without database lookups. Perfect for microservices and distributed systems.
Prerequisites
- Node.js 18+ with TypeScript
- PostgreSQL database
- Postman or similar for testing
- Basic understanding of HTTP and tokens
Step 1: Install Dependencies
npm install jsonwebtoken bcryptjs express cors helmet dotenv
npm install -D @types/jsonwebtoken @types/bcryptjs @types/express @types/node
npm install pg drizzle-orm
Step 2: Set Up Environment Variables
Create .env:
NODE_ENV=development
PORT=3000
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-this
JWT_EXPIRATION=1h
JWT_REFRESH_SECRET=your-refresh-token-secret
JWT_REFRESH_EXPIRATION=7d
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/authdb
# Email (optional)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
Step 3: Define TypeScript Types
Create src/types/auth.ts:
export interface User {
id: string;
email: string;
username: string;
passwordHash: string;
isEmailVerified: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface JWTPayload {
userId: string;
email: string;
iat?: number;
exp?: number;
}
export interface AuthRequest {
email: string;
password: string;
}
export interface AuthResponse {
accessToken: string;
refreshToken: string;
user: Omit<User, 'passwordHash'>;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
declare global {
namespace Express {
interface Request {
user?: JWTPayload;
}
}
}
Step 4: Create User Database Schema
Create src/db/schema.ts:
import { pgTable, serial, varchar, boolean, timestamp, text } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
username: varchar('username', { length: 100 }).unique().notNull(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
isEmailVerified: boolean('is_email_verified').default(false).notNull(),
verificationToken: varchar('verification_token', { length: 255 }),
verificationTokenExpiry: timestamp('verification_token_expiry'),
createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: timestamp('updated_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const refreshTokens = pgTable('refresh_tokens', {
id: serial('id').primaryKey(),
userId: serial('user_id').notNull(),
token: text('token').unique().notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').default(sql`CURRENT_TIMESTAMP`).notNull(),
});
Step 5: Create Password Utility Functions
Create src/utils/password.ts:
import bcryptjs from 'bcryptjs';
export async function hashPassword(password: string): Promise<string> {
const saltRounds = 10;
return bcryptjs.hash(password, saltRounds);
}
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcryptjs.compare(password, hash);
}
export function validatePasswordStrength(password: string): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('Password must contain at least one special character (!@#$%^&*)');
}
return {
isValid: errors.length === 0,
errors,
};
}
Step 6: Create JWT Utility Functions
Create src/utils/jwt.ts:
import jwt from 'jsonwebtoken';
import { JWTPayload } from '../types/auth';
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const JWT_EXPIRATION = process.env.JWT_EXPIRATION || '1h';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'refresh-secret';
const JWT_REFRESH_EXPIRATION = process.env.JWT_REFRESH_EXPIRATION || '7d';
export function generateAccessToken(payload: JWTPayload): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRATION,
});
}
export function generateRefreshToken(payload: JWTPayload): string {
return jwt.sign(payload, JWT_REFRESH_SECRET, {
expiresIn: JWT_REFRESH_EXPIRATION,
});
}
export function verifyAccessToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
} catch (error) {
console.error('Token verification failed:', error);
return null;
}
}
export function verifyRefreshToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, JWT_REFRESH_SECRET) as JWTPayload;
} catch (error) {
console.error('Refresh token verification failed:', error);
return null;
}
}
export function decodeToken(token: string): JWTPayload | null {
try {
return jwt.decode(token) as JWTPayload;
} catch (error) {
return null;
}
}
Step 7: Create Authentication Middleware
Create src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt';
export function authenticateToken(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1]; // Bearer TOKEN
if (!token) {
res.status(401).json({ error: 'Access token required' });
return;
}
const payload = verifyAccessToken(token);
if (!payload) {
res.status(403).json({ error: 'Invalid or expired token' });
return;
}
req.user = payload;
next();
}
export function optionalAuth(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1];
if (token) {
const payload = verifyAccessToken(token);
if (payload) {
req.user = payload;
}
}
next();
}
Step 8: Create Auth Service
Create src/services/authService.ts:
import { db } from '../db';
import { users, refreshTokens } from '../db/schema';
import { eq } from 'drizzle-orm';
import { hashPassword, verifyPassword } from '../utils/password';
import {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
} from '../utils/jwt';
import { AuthResponse, User } from '../types/auth';
export async function registerUser(
email: string,
username: string,
password: string
): Promise<Omit<User, 'passwordHash'>> {
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existingUser.length > 0) {
throw new Error('Email already registered');
}
const passwordHash = await hashPassword(password);
const newUser = await db
.insert(users)
.values({
email,
username,
passwordHash,
})
.returning();
const { passwordHash: _, ...userWithoutPassword } = newUser[0];
return userWithoutPassword;
}
export async function loginUser(
email: string,
password: string
): Promise<AuthResponse> {
const user = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (user.length === 0) {
throw new Error('Invalid email or password');
}
const userData = user[0];
const isPasswordValid = await verifyPassword(password, userData.passwordHash);
if (!isPasswordValid) {
throw new Error('Invalid email or password');
}
const accessToken = generateAccessToken({
userId: userData.id.toString(),
email: userData.email,
});
const refreshToken = generateRefreshToken({
userId: userData.id.toString(),
email: userData.email,
});
// Store refresh token in database
await db.insert(refreshTokens).values({
userId: userData.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
const { passwordHash: _, ...userWithoutPassword } = userData;
return {
accessToken,
refreshToken,
user: userWithoutPassword,
};
}
export async function refreshAccessToken(
refreshToken: string
): Promise<AuthResponse> {
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
throw new Error('Invalid or expired refresh token');
}
const storedToken = await db
.select()
.from(refreshTokens)
.where(eq(refreshTokens.token, refreshToken))
.limit(1);
if (storedToken.length === 0) {
throw new Error('Refresh token not found');
}
const user = await db
.select()
.from(users)
.where(eq(users.id, storedToken[0].userId))
.limit(1);
if (user.length === 0) {
throw new Error('User not found');
}
const userData = user[0];
const newAccessToken = generateAccessToken({
userId: userData.id.toString(),
email: userData.email,
});
const newRefreshToken = generateRefreshToken({
userId: userData.id.toString(),
email: userData.email,
});
// Update refresh token in database
await db
.delete(refreshTokens)
.where(eq(refreshTokens.token, refreshToken));
await db.insert(refreshTokens).values({
userId: userData.id,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
const { passwordHash: _, ...userWithoutPassword } = userData;
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
user: userWithoutPassword,
};
}
export async function logoutUser(refreshToken: string): Promise<void> {
await db.delete(refreshTokens).where(eq(refreshTokens.token, refreshToken));
}
Step 9: Create Auth Routes
Create src/routes/auth.ts:
import express, { Request, Response } from 'express';
import { authenticateToken } from '../middleware/auth';
import * as authService from '../services/authService';
import { validatePasswordStrength } from '../utils/password';
import { AuthRequest, RefreshTokenRequest } from '../types/auth';
const router = express.Router();
// Register
router.post('/register', async (req: Request<{}, {}, AuthRequest>, res: Response) => {
try {
const { email, password } = req.body;
const username = email.split('@')[0];
// Validate password strength
const validation = validatePasswordStrength(password);
if (!validation.isValid) {
return res.status(400).json({ errors: validation.errors });
}
const user = await authService.registerUser(email, username, password);
res.status(201).json({
message: 'User registered successfully',
user,
});
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : 'Registration failed',
});
}
});
// Login
router.post('/login', async (req: Request<{}, {}, AuthRequest>, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
const result = await authService.loginUser(email, password);
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.json(result);
} catch (error) {
res.status(401).json({
error: error instanceof Error ? error.message : 'Login failed',
});
}
});
// Refresh token
router.post('/refresh', async (req: Request<{}, {}, RefreshTokenRequest>, res: Response) => {
try {
const refreshToken = req.body.refreshToken || req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const result = await authService.refreshAccessToken(refreshToken);
res.cookie('refreshToken', result.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json(result);
} catch (error) {
res.status(401).json({
error: error instanceof Error ? error.message : 'Token refresh failed',
});
}
});
// Logout
router.post('/logout', authenticateToken, async (req: Request, res: Response) => {
try {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
await authService.logoutUser(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : 'Logout failed',
});
}
});
// Get current user
router.get('/me', authenticateToken, async (req: Request, res: Response) => {
res.json({
user: req.user,
});
});
export default router;
Step 10: Integrate Into Express App
Create src/index.ts:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import authRoutes from './routes/auth';
const app = express();
// Middleware
app.use(helmet());
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:3000',
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Routes
app.use('/api/auth', authRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
| Component | Purpose | Library |
|---|---|---|
| JWT | Stateless token generation/validation | jsonwebtoken |
| Password Hashing | Secure password storage | bcryptjs |
| Middleware | Route protection | Express middleware |
| Refresh Tokens | Long-lived token rotation | Redis/Database |
| Email Verification | Account activation | nodemailer |
Security Best Practices
Token Storage: Store access tokens in memory, refresh tokens in httpOnly cookies
// ✓ Correct
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
// ✗ Avoid
localStorage.setItem('refreshToken', token);
Password Requirements: Enforce strong passwords
const validation = validatePasswordStrength(password);
if (!validation.isValid) {
throw new Error(validation.errors.join(', '));
}
Token Expiration: Keep access tokens short-lived (15 minutes)
JWT_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
Testing Authentication
Using cURL:
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"SecurePass123!"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"SecurePass123!"}'
# Get current user (replace TOKEN)
curl -X GET http://localhost:3000/api/auth/me \
-H "Authorization: Bearer TOKEN"
Advanced Features
Two-Factor Authentication: Add SMS/email verification OAuth2 Integration: Support Google, GitHub login Rate Limiting: Prevent brute force attacks IP Whitelisting: Restrict access by location
Useful Resources
- JWT.io - JWT debugger and documentation
- OWASP Authentication Cheat Sheet
- jsonwebtoken npm package
- bcryptjs npm package
Conclusion
You've implemented production-ready JWT authentication with refresh token rotation, password hashing, and TypeScript type safety. This foundation supports scalable API development with industry-standard security practices.