Implementing User Authentication with JWT and TypeScript
Back to blog
authenticationjwttypescriptsecuritynodejs

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.

February 17, 2025
5 min read

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}`);
});
ComponentPurposeLibrary
JWTStateless token generation/validationjsonwebtoken
Password HashingSecure password storagebcryptjs
MiddlewareRoute protectionExpress middleware
Refresh TokensLong-lived token rotationRedis/Database
Email VerificationAccount activationnodemailer

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

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.