Introduction
Protecting an API means more than adding a password field to a form. You need a scheme where the server can trust that a request actually comes from who it claims to. JSON Web Tokens solve this cleanly: the server signs a token on login, and subsequent requests carry that token as proof of identity. No session table, no shared state.
What is JWT?
JSON Web Token (JWT) is an open standard (RFC 7519) for transmitting information between parties as a signed JSON object. Because the token is signed, either with a secret key or a public/private key pair, the server can verify it hasn't been tampered with.
Why Use JWT?
JWTs are compact enough to fit in an HTTP header. They're self-contained: the token carries the user's identity and any claims you need, so the server doesn't have to hit the database on every request. And because there's no server-side session to maintain, JWT-based auth scales horizontally without coordination between servers.
How JWT Authentication Works
- The user sends credentials (username and password).
- The server validates them and signs a JWT with a secret key.
- The server returns the token in the response body.
- The client stores the token (localStorage or an HTTP-only cookie).
- Every subsequent request includes the token in the
Authorizationheader. - The server verifies the signature before processing the request.
Implementing JWT Authentication in TypeScript
Below is a step-by-step implementation in Node.js with TypeScript.
Prerequisites
Node.js installed, with basic familiarity with TypeScript and Express.js.
Setting Up the Project
-
Initialize the Project
mkdir jwt-auth-typescript cd jwt-auth-typescript npm init -y -
Install Dependencies
npm install express jsonwebtoken bcryptjs cors dotenv -
Install TypeScript and Types
npm install --save-dev typescript @types/express @types/jsonwebtoken @types/bcryptjs @types/cors -
Initialize TypeScript Configuration
npx tsc --init
Project Structure
jwt-auth-typescript/
├── src/
│ ├── index.ts
│ ├── routes/
│ │ └── auth.ts
│ ├── controllers/
│ │ └── authController.ts
│ ├── models/
│ └── user.ts
├── package.json
├── tsconfig.json
└── .env
Configuring TypeScript (tsconfig.json)
Make sure your tsconfig.json includes:
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
Setting Up Environment Variables
Create a .env file in the root directory:
PORT=5000
JWT_SECRET=your_jwt_secret_key
Creating the Server (index.ts)
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Creating the User Model (models/user.ts)
In a real application you'd use a database. For this example, an in-memory array is enough to demonstrate the auth flow.
interface User {
id: number;
username: string;
password: string;
}
const users: User[] = [];
export default users;
export type { User };
Creating Authentication Routes (routes/auth.ts)
import { Router } from 'express';
import { register, login, protectedRoute } from '../controllers/authController';
const router = Router();
router.post('/register', register);
router.post('/login', login);
router.get('/protected', protectedRoute);
export default router;
Implementing the Controller (controllers/authController.ts)
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import users, { User } from '../models/user';
const JWT_SECRET = process.env.JWT_SECRET || 'secret_key';
export const register = async (req: Request, res: Response) => {
const { username, password } = req.body;
// Check if user exists
const userExists = users.find((user) => user.username === username);
if (userExists) {
return res.status(400).json({ message: 'User already exists' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const newUser: User = {
id: users.length + 1,
username,
password: hashedPassword,
};
users.push(newUser);
res.status(201).json({ message: 'User registered successfully' });
};
export const login = async (req: Request, res: Response) => {
const { username, password } = req.body;
// Find user
const user = users.find((user) => user.username === username);
if (!user) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Check password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(400).json({ message: 'Invalid credentials' });
}
// Sign token
const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
};
export const protectedRoute = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.sendStatus(401);
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
(req as any).user = user;
next();
});
};
Protecting Routes
Move the verification logic into a middleware array so it can be reused across routes:
export const protectedRoute = [
(req: Request, res: Response, next: NextFunction) => {
// Middleware to verify JWT
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.sendStatus(401);
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
(req as any).user = user;
next();
});
},
(req: Request, res: Response) => {
// Protected route logic
res.json({ message: 'This is a protected route', user: (req as any).user });
}
];
Testing the Application
-
Compile TypeScript
npx tsc -
Run the Server
node dist/index.js -
Register a User
POST http://localhost:5000/api/auth/register Content-Type: application/json { "username": "testuser", "password": "password123" } -
Login
POST http://localhost:5000/api/auth/login Content-Type: application/json { "username": "testuser", "password": "password123" }You will receive a JWT token in the response.
-
Access Protected Route
GET http://localhost:5000/api/auth/protected Authorization: Bearer <your_jwt_token>You should receive a response indicating access is granted.
Security Considerations
- Always use HTTPS in production. A JWT sent over plain HTTP can be intercepted.
- Set short expiration times. An hour is reasonable for access tokens; use refresh tokens for longer sessions.
- Store tokens in HTTP-only cookies when possible, not localStorage. localStorage is readable by any JavaScript on the page.
- Validate all user inputs to prevent injection attacks.
Conclusion
JWT authentication is a practical fit for stateless APIs. The implementation above covers registration, password hashing with bcrypt, token signing and verification, and route protection as middleware. From this base, the next natural steps are refresh token rotation and moving the in-memory user store to a real database.
Further Reading
Share Your Thoughts
If you found this useful or have questions, reach out on Twitter.
By Samuel Fajreldines, specialist in JavaScript, TypeScript, DevOps, and Serverless Architecture.