All Tutorials
TypeScript
Express
JWT
Authentication
Node.js
backend

Build a JWT Authentication API with Express and TypeScript

A code-along tutorial: add JWT authentication to a Node.js API using Express, TypeScript, jsonwebtoken, and bcryptjs. Three routes, a password-hashing register, a login that returns a signed token, and a protected endpoint. Every file is here to paste and run.

13 min read2026-07-25

The short answer

You build a small auth API with three routes in TypeScript using Express: POST /register hashes a password with bcryptjs and stores the user, POST /login verifies the password and returns a signed JWT, and GET /me reads the Bearer token from the Authorization header, verifies it with jsonwebtoken, and returns the current user. The project has five files: a types module, an in-memory store, a JWT sign/verify helper, an authenticate middleware, and the Express routes. Setup takes about ten minutes. Every file is in this page; paste it into a fresh project and it runs.

  • Setup time: about 10 minutes
  • Runtime dependencies: 3 (Express, jsonwebtoken, bcryptjs)
  • Routes: POST /register, POST /login, GET /me (protected)
  • Stack: TypeScript, Express, jsonwebtoken, bcryptjs, tsx
  • Storage: in-memory (no database to install)
  • Every file is in this page: paste into a fresh project and run

What you will build

By the end of this tutorial you will have a working authentication API with three routes: a register endpoint that hashes a password before storing it, a login endpoint that checks the hash and returns a signed JWT, and a protected endpoint that reads the token from an Authorization header and returns the current user. The five source files wire together cleanly: a types module, an in-memory user store, a JWT helper, an authenticate middleware, and the Express routes. Every file is in this page. Paste each one into a fresh project, follow the setup steps, and it runs. No repo to clone.

Why this matters: most real backends gate at least some routes behind authentication. This tutorial shows the exact pattern (hash on the way in, compare on the way in again, sign a token, verify it on protected routes) so you understand what is happening instead of copying a library that hides the details.

The stack, and why each piece is here

Four dependencies, each standard for its job.

Express handles HTTP routing; it is the framework covered in most free Node.js courses and the one you are most likely to find in an existing codebase. jsonwebtoken signs and verifies JWTs; it is the most widely used JWT library for Node (~10M downloads a week). bcryptjs hashes passwords; it is pure JavaScript so it installs without native bindings on any platform (~2M downloads a week). TypeScript ties it together so every shape is written down once and checked everywhere.

JWT (JSON Web Token) is the standard for stateless auth in HTTP APIs. The server signs a small JSON payload with a secret key and gives it to the client. The client sends it back in the Authorization header on every protected request. The server verifies the signature and trusts the payload, no session store needed. This is how most REST APIs and mobile backends work in practice.

Setup

Create a fresh project and install the packages. Express, jsonwebtoken, and bcryptjs are the three runtime dependencies; the rest is dev tooling.

bash
mkdir auth-api
cd auth-api
npm init -y
npm install express jsonwebtoken bcryptjs
npm install -D typescript tsx @types/node @types/express @types/jsonwebtoken @types/bcryptjs

Add a tsconfig.json at the project root:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

Then open package.json, add "type": "module", and set these scripts:

json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

All five source files below go in a src/ directory you create in the project root.

Define the types

Start with the shape of the data. One types module, imported everywhere, keeps the whole API consistent. User is the stored record (email + hashed password). PublicUser is what routes return: the same shape with the hash stripped out, so a password hash never leaves the server. TokenPayload is what gets signed into and read back from the JWT.

Create src/types.ts:

typescript
export interface User {
  id: number;
  email: string;
  passwordHash: string;
}

export interface PublicUser {
  id: number;
  email: string;
}

export interface TokenPayload {
  sub: number;
  email: string;
}

Build the in-memory user store

The store keeps users in a plain array, the same approach as the REST API tutorial. It resets on restart (no database to configure) so you can focus on the auth pattern. Swapping this for a real database later is a change to this one file; the routes stay the same.

Create src/db.ts:

typescript
import type { User } from "./types.js";

const users: User[] = [];
let nextId = 1;

export function findByEmail(email: string): User | undefined {
  return users.find((u) => u.email === email);
}

export function findById(id: number): User | undefined {
  return users.find((u) => u.id === id);
}

export function createUser(email: string, passwordHash: string): User {
  const user: User = { id: nextId++, email, passwordHash };
  users.push(user);
  return user;
}

Write the JWT helpers

This module wraps jsonwebtoken so the rest of the app never calls it directly. Two functions: signToken returns a signed string, verifyToken decodes and validates it. When verification fails (wrong secret, expired token, tampered payload) jsonwebtoken throws, and the middleware catches it.

The secret comes from an env var. In development, the fallback string works fine. In production, set JWT_SECRET to a random 256-bit value and never commit it.

Create src/auth.ts:

typescript
import jwt from "jsonwebtoken";
import type { TokenPayload } from "./types.js";

const SECRET = process.env.JWT_SECRET ?? "dev-secret-change-in-production";
const EXPIRES_IN = "24h";

export function signToken(payload: TokenPayload): string {
  return jwt.sign(payload, SECRET, { expiresIn: EXPIRES_IN });
}

export function verifyToken(token: string): TokenPayload {
  return jwt.verify(token, SECRET) as TokenPayload;
}

Write the authenticate middleware

Express middleware is a function that runs before a route handler. This one reads the Authorization header, strips the Bearer prefix, verifies the token, and attaches the decoded payload to req.user so route handlers can read it without re-verifying. If anything is wrong (missing header, bad token, expired) it sends a 401 and stops the chain by not calling next().

The declare global block extends Express's Request type so TypeScript knows req.user exists on protected routes. This is the standard pattern for Express type augmentation.

Create src/middleware.ts:

typescript
import { type Request, type Response, type NextFunction } from "express";
import { verifyToken } from "./auth.js";
import type { TokenPayload } from "./types.js";

declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export function authenticate(req: Request, res: Response, next: NextFunction): void {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    res.status(401).json({ error: "Missing or malformed Authorization header." });
    return;
  }
  const token = header.slice(7);
  try {
    req.user = verifyToken(token);
    next();
  } catch {
    res.status(401).json({ error: "Token is invalid or has expired." });
  }
}

Build the three routes

Three routes in an Express Router. Notice the validation pattern on /register: the email check is lightweight (does it contain @), the password check enforces a minimum length, and a duplicate-email check returns 409 Conflict. The 401 response on /login uses the same message whether the email does not exist or the password is wrong, so you never tell an attacker which half was right.

Create src/routes.ts:

typescript
import { Router, type Request, type Response } from "express";
import bcrypt from "bcryptjs";
import { signToken } from "./auth.js";
import { createUser, findByEmail, findById } from "./db.js";
import { authenticate } from "./middleware.js";
import type { PublicUser } from "./types.js";

export const router = Router();

// POST /register: { email, password } -> 201 with the new user (no password hash).
router.post("/register", async (req: Request, res: Response) => {
  const { email, password } = req.body ?? {};
  if (typeof email !== "string" || !email.includes("@")) {
    res.status(400).json({ error: "A valid email is required." });
    return;
  }
  if (typeof password !== "string" || password.length < 8) {
    res.status(400).json({ error: "Password must be at least 8 characters." });
    return;
  }
  if (findByEmail(email)) {
    res.status(409).json({ error: "An account with that email already exists." });
    return;
  }
  const passwordHash = await bcrypt.hash(password, 10);
  const user = createUser(email, passwordHash);
  const publicUser: PublicUser = { id: user.id, email: user.email };
  res.status(201).json(publicUser);
});

// POST /login: { email, password } -> { token } on success, 401 on bad credentials.
router.post("/login", async (req: Request, res: Response) => {
  const { email, password } = req.body ?? {};
  const user = typeof email === "string" ? findByEmail(email) : undefined;
  if (!user || typeof password !== "string") {
    res.status(401).json({ error: "Invalid email or password." });
    return;
  }
  const match = await bcrypt.compare(password, user.passwordHash);
  if (!match) {
    res.status(401).json({ error: "Invalid email or password." });
    return;
  }
  const token = signToken({ sub: user.id, email: user.email });
  res.json({ token });
});

// GET /me: returns the current user. Requires a valid Bearer token.
router.get("/me", authenticate, (req: Request, res: Response) => {
  const user = findById(req.user!.sub);
  if (!user) {
    res.status(404).json({ error: "User not found." });
    return;
  }
  const publicUser: PublicUser = { id: user.id, email: user.email };
  res.json(publicUser);
});

Wire up the app

The entry point creates the Express app, adds the JSON body parser so req.body is a real object, mounts the router, and starts listening. Create src/index.ts:

typescript
import express from "express";
import { router } from "./routes.js";

const app = express();
app.use(express.json());
app.use(router);

const port = Number(process.env.PORT) || 3000;
app.listen(port, () => {
  console.log(`Auth API listening on http://localhost:${port}`);
});

Run and test all three routes

Start the server in dev mode:

bash
npm run dev
# -> Auth API listening on http://localhost:3000

In a second terminal, walk through the full auth flow with curl. Register first:

bash
# Register
curl -X POST http://localhost:3000/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"secret123"}'
# -> {"id":1,"email":"[email protected]"}

Then log in to get a token:

bash
# Login
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"secret123"}'
# -> {"token":"eyJhbGciOi..."}

Copy the token value from the response, then call the protected endpoint:

bash
# Call a protected route
curl http://localhost:3000/me \
  -H "Authorization: Bearer eyJhbGciOi..."
# -> {"id":1,"email":"[email protected]"}

Test the error cases too. They are the part that matters in real auth:

  • Call /me without a token: should get 401 Missing or malformed Authorization header.
  • Call /me with the string Bearer garbage: should get 401 Token is invalid or has expired.
  • Register the same email twice: should get 409 An account with that email already exists.
  • Log in with the wrong password: should get 401 Invalid email or password. (same message as wrong email, intentional)

Go deeper on Node.js auth

Want the full picture on JWT security tradeoffs? freeCodeCamp's Back End Development and APIs certification covers Express and APIs from scratch for free. For the data side, see our backend developer learn path which sequences the free courses in order, and keep the Node.js cheat sheet open while you build.

What to try next

You have a working auth API. Three moves take it toward something you would run in production.

Add refresh tokens. A 24-hour JWT means users are logged out every day. The standard fix is a short-lived access token (15 min) plus a long-lived refresh token stored in an httpOnly cookie. When the access token expires, a POST /refresh route issues a new one without making the user log in again. The middleware barely changes; the route is new.

Add a real database. The in-memory store loses users on restart. Swap src/db.ts for PostgreSQL with Drizzle or Prisma; the routes and middleware stay the same because the store interface does not change. For a real-world example, see how the URL shortener tutorial adds SQLite persistence at /tutorials/build-url-shortener-express-typescript-sqlite.

Add rate limiting. Install express-rate-limit and add a tight limiter on /login (10 requests per 15 minutes per IP). Without it, the login route is open to credential-stuffing attacks. It is a four-line middleware add.

For the full backend learning path, /learn/backend sequences free courses in order, and the /roadmap/backend shows the skills that come after.

Next step: test the auth routes

Auth is exactly the code you want tests around, because a broken login or a leaky protected route is a security bug, not just a functional one. Our follow-up, test an Express + TypeScript API with Vitest and Supertest, walks through the pattern (register, login, a 401 without a token) so you can lock this behavior down before you add refresh tokens or a database.

Frequently asked questions

Why bcryptjs instead of bcrypt?

bcryptjs is pure JavaScript so it installs without native bindings on any platform (Windows, Linux, Mac, ARM, whatever your CI runs). bcrypt is slightly faster but requires a C++ build step. For a tutorial where setup time matters and throughput is not the bottleneck, bcryptjs is the right default. For a production service hashing thousands of passwords a minute, bcrypt's speed advantage becomes real.

What is a JWT and what goes inside it?

A JWT is a base64-encoded JSON object (the payload) signed with a secret. The payload here contains sub (the user id) and email. Anyone can base64-decode the payload and read it, so never put a password or sensitive data in a JWT. The signature is what makes it trustworthy: only the server knows the secret, so only the server can produce a valid signature, and any change to the payload invalidates it.

Why does /login return the same 401 for wrong email and wrong password?

Returning different messages would tell an attacker which email addresses have accounts. "Invalid email or password" gives nothing away. This is standard auth practice.

Where should I store the JWT secret in production?

In an environment variable, never in code or a committed config file. On Railway, Render, or Fly.io, set JWT_SECRET in the dashboard. Use a random 256-bit value (run node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" to generate one). Rotate it if you suspect it was leaked.

How do I expire tokens before they time out?

The standard approach is a token blocklist: store revoked token ids in Redis and check it in the authenticate middleware. For a simpler option that avoids a blocklist, keep access tokens short-lived (15 minutes) and handle logout by discarding the token on the client. The server never needs to forget it because it expires fast.

Can I use this with a React or Next.js frontend?

Yes. The frontend calls POST /login with email and password, receives the token, stores it (localStorage for SPAs, an httpOnly cookie for better security), and adds Authorization: Bearer <token> to every protected request. Next.js route handlers can act as a proxy if you want the token to stay out of client-side JavaScript.

Why use middleware instead of calling verifyToken inside each protected route?

Middleware runs once and attaches req.user. Protected routes read req.user instead of duplicating the verify call. Adding a second protected route later is one line (router.get('/settings', authenticate, handler)) with no auth logic to copy. This is the standard Express auth pattern.

Is the in-memory store safe for a tutorial?

Yes for learning. Users are lost on restart, which is fine here because the goal is understanding the auth pattern, not data persistence. The db.ts module exposes three functions (findByEmail, findById, createUser); replace their internals with Drizzle queries when you are ready and the rest of the app changes nothing.

Keep going on FreeCodingCourses