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

  1. The user sends credentials (username and password).
  2. The server validates them and signs a JWT with a secret key.
  3. The server returns the token in the response body.
  4. The client stores the token (localStorage or an HTTP-only cookie).
  5. Every subsequent request includes the token in the Authorization header.
  6. 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

  1. Initialize the Project

    mkdir jwt-auth-typescript
    cd jwt-auth-typescript
    npm init -y
  2. Install Dependencies

    npm install express jsonwebtoken bcryptjs cors dotenv
  3. Install TypeScript and Types

    npm install --save-dev typescript @types/express @types/jsonwebtoken @types/bcryptjs @types/cors
  4. 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

  1. Compile TypeScript

    npx tsc
  2. Run the Server

    node dist/index.js
  3. Register a User

    POST http://localhost:5000/api/auth/register
    Content-Type: application/json
    
    {
      "username": "testuser",
      "password": "password123"
    }
  4. 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.

  5. 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.