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 frameworkjsonwebtoken- Create and verify JWT tokensbcryptjs- Hash passwords securelycookie-parser- Parse cookie headersdotenv- 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:
-
Use environment variables for secrets
const secret = process.env.JWT_SECRET; // ✅ Good -
Use httpOnly cookies
res.cookie('token', jwt, { httpOnly: true }); // ✅ Good -
Use HTTPS in production
secure: process.env.NODE_ENV === 'production' // ✅ Good -
Validate all user input
if (!username || !password) { /* error */ } // ✅ Good -
Use bcrypt for passwords
await bcrypt.hash(password, 10); // ✅ Good
❌ DON'T:
-
Store tokens in localStorage
localStorage.setItem('token', jwt); // ❌ Vulnerable to XSS -
Send tokens in response body
res.json({ token: jwt }); // ❌ Should be in httpOnly cookie -
Use weak secrets
const secret = 'secret'; // ❌ Too short, predictable -
Store plain text passwords
db.users.create({ password: 'plain' }); // ❌ Always hash! -
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
- JWT.io Debugger - Decode and verify JWT tokens
- bcrypt explained
- Express Cookie Options
- OWASP Session Management
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.