Build a Secure Auth API with Express

Build a JWT auth API with bcrypt, RBAC middleware, and timing attack prevention.

Introduction

30 Second Summary

Every time you log in to an app, there's a system behind the scenes deciding who you are and what you're allowed to do. That system is authentication and authorization.

In this project, you will build a complete authentication and authorization API from scratch using Express and TypeScript. You'll implement password hashing, token-based auth, and role-based access control.

What You'll Build

A production-style REST API with six endpoints that handles user registration, login, JWT-based authentication, and role-based authorization.

By the end of this project, you'll have:

  • A working REST API that implements registration, login, and token-based authentication from scratch.
  • Role-based authorization middleware that enforces a permission matrix across API endpoints.
  • Production security best practices including generic error messages, timing attack mitigation, and short-lived tokens.
  • Secret Mission: Add refresh token rotation with reuse detection to solve the stateless JWT revocation problem.

Are there any prerequisites?

You need basic JavaScript knowledge (variables, functions, async/await). No TypeScript experience is required.

Set Up the Project

Every authentication system starts with a solid project foundation. Before writing any security logic, you need a working TypeScript environment with the right packages in place.

In this step, you will create the project directory, install Express 5, the jose JWT library, and bcrypt for password hashing. You will also configure Node.js to run TypeScript files directly, with no build step required.

In this step, get ready to:
  • Initialize the project and install all dependencies.
  • Configure TypeScript for Node.js native type stripping.
  • Verify the full stack works with a minimal Express server.
Initialize the project and install packages

Your project needs a directory, a package.json to track dependencies, and three core packages: Express for the HTTP server, jose for JWTs, and bcrypt for password hashing.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project directory, move into it, and initialize a new npm project by running these commands:
mkdir auth-api
cd auth-api
npm init -y
  • Confirm the directory was created by running:
ls

You should see package.json in the output.

  • Install the project's main dependencies by running:
npm install express@5.2.1 jose@6.2.3 bcrypt@6.0.0
  • Install TypeScript and type definitions as dev dependencies by running:
npm install --save-dev @types/express@5.0.6 @types/bcrypt@5.0.2 typescript@5.8.3

What are these packages?

  • express is the HTTP framework that handles routes and middleware.
  • jose is a modern JWT library that uses the Web Crypto API and works natively with ESM.
  • bcrypt hashes passwords so they are never stored in plain text.
  • The @types/ packages and typescript are dev-only. They provide editor IntelliSense but are not needed at runtime because Node.js strips types natively.

Seeing install errors?

  • If you see a permissions error, make sure you are inside the auth-api directory (run pwd to check).
  • If bcrypt fails to install, it may need prebuilt binaries for your platform. Run node -p process.arch to confirm your architecture and check the bcrypt npm page for supported platforms.

Still stuck? Help me troubleshoot this npm install error.

The npm init -y command generates a default package.json with placeholder fields. You need to update it to enable ESM imports and add useful scripts.

  • Open package.json in Neovim by running:
nvim package.json
  • Replace all the content in the file with the following:
{
  "name": "auth-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "express": "5.2.1",
    "jose": "6.2.3",
    "bcrypt": "6.0.0"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/bcrypt": "5.0.2",
    "typescript": "5.8.3"
  }
}

What did you change?

  • "type": "module" tells Node.js to treat .ts and .js files as ES modules, enabling import/export syntax.
  • The start script runs your server directly with node src/server.ts. No compilation step needed.
  • The typecheck script runs tsc --noEmit to check for type errors without producing output files.
  • Save the file in Neovim.

✔️ Awesome, I've got everything!

Great. Double check you have saved package.json before moving on.

ⓧ I'd like to double check the full code

Your package.json should look exactly like this:

{
  "name": "auth-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "express": "5.2.1",
    "jose": "6.2.3",
    "bcrypt": "6.0.0"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/bcrypt": "5.0.2",
    "typescript": "5.8.3"
  }
}
Configure TypeScript for native type stripping

Node.js 26 can run TypeScript files directly by stripping type annotations at load time. For this to work, your tsconfig.json needs to enable the right settings.

  • Create tsconfig.json by running:
nvim tsconfig.json
  • Add the following content to the file:
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true
  }
}

What do these settings do?

  • noEmit tells TypeScript to only type-check, never produce JavaScript output files.
  • erasableSyntaxOnly ensures you only use TypeScript syntax that Node.js can strip without code generation. This means no enum or parameter properties.
  • verbatimModuleSyntax requires import type for type-only imports. Without it, Node.js would try to import type declarations as values and fail at runtime.
  • rewriteRelativeImportExtensions lets the type checker understand .ts extensions in your import paths.
  • Save the file in Neovim.

✔️ Awesome, I've got everything!

Great. Double check you have saved tsconfig.json before moving on.

ⓧ I'd like to double check the full code

Your tsconfig.json should look exactly like this:

{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true
  }
}
  • Create the src directory by running:
mkdir src
  • Confirm the directory exists by running:
ls

You should see node_modules, package.json, package-lock.json, tsconfig.json, and src in the output.

Verify all tools work together

The final test is to prove that Node.js type stripping, Express, and ESM imports all work together. You will create a minimal server, start it, and confirm it responds to a request.

  • Create src/server.ts by running:
nvim src/server.ts
  • Add the following code to the file:
import express from 'express';

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

app.get('/', (req, res) => {
  res.json({ message: 'Auth API is running' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

  • import express from 'express' pulls in the Express framework using ESM syntax. This only works because "type": "module" is set in package.json.
  • express.json() is middleware that parses incoming JSON request bodies. Every endpoint in the auth API will receive JSON.
  • The / route returns a simple JSON message to confirm the server is alive.
  • app.listen() starts the server on port 3000 and prints a confirmation message to the terminal.
  • Save the file in Neovim.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/server.ts before moving on.

ⓧ I'd like to double check the full code

Your src/server.ts should look exactly like this:

import express from 'express';

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

app.get('/', (req, res) => {
  res.json({ message: 'Auth API is running' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
  • Start the server by running:
node src/server.ts

You should see Server running on http://localhost:3000 printed in your terminal.

Why does this work without compiling?

Node.js 26 reads the .ts file, strips all type annotations at load time, and executes the remaining JavaScript. No tsc build step, no watch mode, no output directory.

  • Open a second terminal tab or split (the server is still running in the first one).
  • Send a test request by running:
curl http://localhost:3000

You should see the following JSON response:

{"message":"Auth API is running"}
  • Switch back to the terminal running the server and press Ctrl+C to stop it.

Server not starting?

  • If you see an error about import syntax, make sure "type": "module" is in your package.json.
  • If you see a port conflict error, another process is using port 3000. Stop it or change the PORT value in src/server.ts temporarily.
  • If curl hangs or returns "connection refused", make sure the server is running in the other terminal and shows the "Server running" message.

Still stuck? Help me debug why my Express TypeScript server won't start.

Your environment is ready. Next up, you will build the registration endpoint that hashes passwords and stores user credentials.

Build Registration with Password Hashing

Your Express server is up and running. Now it's time to build the first real feature: user registration with secure password storage.

Storing passwords as plain text is a critical security mistake. In this step, you'll use bcrypt to hash passwords before storing them, making them unreadable even if your data store is compromised.

In this step, get ready to:
  • Define TypeScript types for users, roles, and permissions.
  • Build the registration endpoint with password hashing.
  • Test the endpoint with curl to confirm it works.
Create the in-memory user store and types

Before writing any endpoint logic, you need to define the shape of your data. TypeScript types ensure your auth system is consistent across every file.

  • Create a new file src/types.ts in Neovim and add the following code:
export type Role = 'viewer' | 'editor' | 'admin';

export type Permission =
  | 'posts:read'
  | 'posts:create'
  | 'posts:update'
  | 'posts:delete'
  | 'users:read'
  | 'users:delete';

export interface User {
  id: string;
  email: string;
  passwordHash: string;
  role: Role;
}

What does this code do?

Role defines the three access levels your API supports. Permission lists every action a user can perform, using a resource:action naming pattern.

The User interface stores passwordHash instead of a plain password. You'll never store the original password anywhere.

  • Save src/types.ts.
  • Create a new file src/store.ts in Neovim and add the following code:
import type { User } from './types.ts';

export const users = new Map<string, User>();

Why a Map instead of an array?

A Map lets you look up users by ID in constant time. In production, this would be a database. The auth patterns you build on top work the same either way.

  • Save src/store.ts.
  • Verify both files pass the type checker by running:
npx tsc --noEmit

You should see no output, which means zero type errors.

Seeing a type error?

Make sure your import in src/store.ts uses import type (not just import). Node.js type stripping requires the type keyword on type-only imports.

Also check that the file extension in the import path is .ts (e.g. './types.ts'). Node.js requires explicit extensions.

Help me debug my type errors

  • Create a new file src/config.ts in Neovim and add the following code:
import type { Role, Permission } from './types.ts';

export const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || 'dev-secret-change-in-production'
);

export const ACCESS_TOKEN_EXPIRY = '15m';

export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  viewer: ['posts:read', 'users:read'],
  editor: ['posts:read', 'posts:create', 'posts:update', 'users:read'],
  admin: ['posts:read', 'posts:create', 'posts:update', 'posts:delete', 'users:read', 'users:delete'],
};

What is the RBAC permission matrix?

RBAC (Role-Based Access Control) maps each role to a list of allowed actions. A viewer can only read. An editor can read and create. An admin can do everything including deletions.

JWT_SECRET encodes the signing key for tokens you'll issue in the next step. ACCESS_TOKEN_EXPIRY sets tokens to expire after 15 minutes, limiting the window if a token is stolen.

  • Save src/config.ts.
  • Run the type checker again to confirm everything connects:
npx tsc --noEmit

You should see no output again. All three files are type-safe.

Getting 'cannot find module' errors?

Check that each import type path ends with .ts. For example, './types.ts' not './types'. Node.js does not resolve extensions automatically.

Help me fix my import errors in config.ts

✔️ Awesome, I've got everything!

Great. Make sure you've saved all three files before moving on.

ⓧ I'd like to double check the full code

export type Role = 'viewer' | 'editor' | 'admin';

export type Permission =
  | 'posts:read'
  | 'posts:create'
  | 'posts:update'
  | 'posts:delete'
  | 'users:read'
  | 'users:delete';

export interface User {
  id: string;
  email: string;
  passwordHash: string;
  role: Role;
}
import type { User } from './types.ts';

export const users = new Map<string, User>();
import type { Role, Permission } from './types.ts';

export const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET || 'dev-secret-change-in-production'
);

export const ACCESS_TOKEN_EXPIRY = '15m';

export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  viewer: ['posts:read', 'users:read'],
  editor: ['posts:read', 'posts:create', 'posts:update', 'users:read'],
  admin: ['posts:read', 'posts:create', 'posts:update', 'posts:delete', 'users:read', 'users:delete'],
};
Build the /auth/register endpoint

With your types and config in place, you can now build the endpoint that accepts user credentials and stores them securely.

  • Create the src/routes/ directory by running:
mkdir src/routes
  • Create a new file src/routes/auth.ts in Neovim and add the following code:
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { randomUUID } from 'node:crypto';
import { users } from '../store.ts';
import type { Role } from '../types.ts';

export const authRouter = Router();

authRouter.post('/register', async (req, res) => {
  const { email, password, role } = req.body;

  if (!email || !password) {
    res.status(400).json({ error: 'Email and password are required' });
    return;
  }

  // Check if a user with this email already exists
  const existingUser = [...users.values()].find(u => u.email === email);
  if (existingUser) {
    // Generic message prevents email enumeration attacks
    res.status(409).json({ error: 'Registration failed' });
    return;
  }

  // Hash the password with bcrypt at cost factor 12
  const passwordHash = await bcrypt.hash(password, 12);
  const id = randomUUID();
  const validRoles: Role[] = ['viewer', 'editor', 'admin'];
  const userRole: Role = validRoles.includes(role) ? role : 'viewer';

  users.set(id, { id, email, passwordHash, role: userRole });

  res.status(201).json({ message: 'User registered', userId: id });
});

What does this code do?

  • bcrypt.hash(password, 12) transforms the plain password into an irreversible hash. The cost factor 12 means bcrypt runs 2^12 (4096) iterations, making brute-force attacks extremely slow.
  • The generic error message 'Registration failed' on duplicate emails prevents attackers from discovering which email addresses are registered (email enumeration).
  • Role validation falls back to 'viewer' if the client sends an invalid role. This prevents privilege escalation through the registration endpoint.
  • Save src/routes/auth.ts.

Now you need to mount this router in your main server file so Express knows to forward requests to it.

  • Open src/server.ts in Neovim.
  • Find this line at the top of the file:
import express from 'express';
  • Add the authRouter import directly below it, so the top of your file looks like this:
import express from 'express';
import { authRouter } from './routes/auth.ts';
  • Find this line further down:
app.use(express.json());
  • Add the route mount directly below it:
app.use(express.json());

app.use('/auth', authRouter);

What does mounting do?

app.use('/auth', authRouter) tells Express to forward any request starting with /auth to your auth router. So POST /auth/register hits the authRouter.post('/register', ...) handler you just wrote.

  • Save src/server.ts.
  • Start the server to confirm everything compiles by running:
node src/server.ts

You should see Server running on http://localhost:3000 in your terminal.

Server won't start?

If you see an error about Cannot find module, check that the import path in src/server.ts is './routes/auth.ts' with the .ts extension. Also confirm you created the file at src/routes/auth.ts (inside the routes subdirectory, not directly in src/).

If the port is already in use, stop the previous server process with Ctrl+C first.

Help me debug my server startup error

✔️ Awesome, I've got everything!

Great. Make sure the server is running before moving to the next substep.

ⓧ I'd like to double check the full code

import { Router } from 'express';
import bcrypt from 'bcrypt';
import { randomUUID } from 'node:crypto';
import { users } from '../store.ts';
import type { Role } from '../types.ts';

export const authRouter = Router();

authRouter.post('/register', async (req, res) => {
  const { email, password, role } = req.body;

  if (!email || !password) {
    res.status(400).json({ error: 'Email and password are required' });
    return;
  }

  const existingUser = [...users.values()].find(u => u.email === email);
  if (existingUser) {
    res.status(409).json({ error: 'Registration failed' });
    return;
  }

  const passwordHash = await bcrypt.hash(password, 12);
  const id = randomUUID();
  const validRoles: Role[] = ['viewer', 'editor', 'admin'];
  const userRole: Role = validRoles.includes(role) ? role : 'viewer';

  users.set(id, { id, email, passwordHash, role: userRole });

  res.status(201).json({ message: 'User registered', userId: id });
});
import express from 'express';
import { authRouter } from './routes/auth.ts';

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

app.use('/auth', authRouter);

app.get('/', (req, res) => {
  res.json({ message: 'Auth API is running' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Test registration with curl

Your registration endpoint is live. Time to test it by registering users with different roles and confirming the responses.

  • Open a second terminal window (keep the server running in the first one).
  • Register a viewer by running:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "viewer@test.com", "password": "password123", "role": "viewer"}'

You should see a response like {"message":"User registered","userId":"some-uuid-here"} with a 201 status.

  • Register an editor by running:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "editor@test.com", "password": "password123", "role": "editor"}'
  • Register an admin by running:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@test.com", "password": "password123", "role": "admin"}'
  • Test the duplicate email protection by registering the same email again:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "viewer@test.com", "password": "different", "role": "viewer"}'

This time you should see {"error":"Registration failed"} with a 409 status. Notice the message gives no hint that the email already exists.

Why generic error messages?

If the API returned "Email already registered", an attacker could probe thousands of emails to build a list of your users. The generic message 'Registration failed' tells the client something went wrong without revealing why.

Getting 'Cannot POST /auth/register'?

Make sure you added app.use('/auth', authRouter) in src/server.ts and restarted the server. Express only picks up code changes on restart.

If you see a Connection refused error, confirm the server is still running in your other terminal window.

Help me fix my registration endpoint

Your registration system is secure and working. Next up, you'll build the login endpoint that verifies credentials and issues JWT access tokens.

Build Login and JWT Issuance

Your registration endpoint is storing users with securely hashed passwords. Now the API needs a way for those users to prove their identity and receive access.

In this step, you'll build the login endpoint that verifies credentials and issues a short-lived JWT (JSON Web Token). You'll also implement a timing attack mitigation technique that prevents attackers from discovering which emails exist in your system.

In this step, get ready to:
  • Build the /auth/login endpoint with JWT signing.
  • Implement timing attack mitigation on failed lookups.
  • Test the login flow with valid and invalid credentials.
Build the /auth/login endpoint

The login flow has three jobs: find the user by email, verify their password against the stored hash, and issue a signed token if everything checks out.

  • In src/routes/auth.ts, find the imports at the top of the file:
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { randomUUID } from 'node:crypto';
import { users } from '../store.ts';
import type { Role } from '../types.ts';
  • Replace the entire import block with this updated version that adds SignJWT from jose and your config values:
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { SignJWT } from 'jose';
import { randomUUID } from 'node:crypto';
import { users } from '../store.ts';
import { JWT_SECRET, ACCESS_TOKEN_EXPIRY } from '../config.ts';
import type { Role } from '../types.ts';

What are these new imports?

SignJWT is the jose library's class for building and signing JSON Web Tokens. JWT_SECRET and ACCESS_TOKEN_EXPIRY are the signing key and token lifetime you defined in src/config.ts earlier.

  • Now scroll to the bottom of src/routes/auth.ts, below the closing }); of the register endpoint. Add the login handler by copying this code below it:
authRouter.post('/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    res.status(400).json({ error: 'Email and password are required' });
    return;
  }

  // Look up the user by email
  const user = [...users.values()].find(u => u.email === email);

  if (!user) {
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  // Compare submitted password against the stored hash
  const passwordMatch = await bcrypt.compare(password, user.passwordHash);
  if (!passwordMatch) {
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  // Sign a JWT with the user's ID and role as claims
  const token = await new SignJWT({ sub: user.id, role: user.role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(ACCESS_TOKEN_EXPIRY)
    .sign(JWT_SECRET);

  res.json({ token });
});

What does this code do?

  • [...users.values()].find(u => u.email === email) searches the user store for a matching email address.
  • bcrypt.compare() hashes the submitted password and compares it against the stored hash. It never decrypts the original.
  • new SignJWT({ sub: user.id, role: user.role }) embeds the user's ID and role into the token payload so downstream middleware knows who made the request.
  • .setProtectedHeader({ alg: 'HS256' }) pins the signing algorithm explicitly. This prevents the "algorithm none" attack where an attacker strips the signature.
  • .setExpirationTime(ACCESS_TOKEN_EXPIRY) makes the token expire after 15 minutes, limiting damage if it's stolen.

Why the same error message for both cases?

Both the "user not found" and "wrong password" branches return the identical string Authentication failed. If the API returned "email not found" vs. "wrong password", an attacker could enumerate which emails are registered in your system.

  • Save src/routes/auth.ts.
  • Restart your server by pressing Ctrl+C in the terminal to stop it, then run:
node src/server.ts

You should see Server running on http://localhost:3000 with no errors.

Seeing an import error?

  • Check that you imported SignJWT from 'jose' (not 'jsonwebtoken' or another package).
  • Make sure your config import uses the .ts extension: '../config.ts'. Node.js type stripping requires explicit extensions.
  • Verify that JWT_SECRET and ACCESS_TOKEN_EXPIRY are exported from src/config.ts (not import type).

Help me debug my import error in auth.ts

Implement timing attack mitigation

The login handler has a subtle vulnerability. When a user is not found, the server skips the bcrypt.compare() call and returns immediately. When a user IS found (but the password is wrong), it runs the comparison first.

That timing difference lets an attacker measure response times to figure out which emails are registered. The fix is to run a dummy hash operation when the user is not found, equalizing response time in both cases.

  • In src/routes/auth.ts, find the if (!user) block inside the login handler:
  if (!user) {
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }
  • Add a dummy bcrypt.hash() call on the line before res.status(401), so the block now looks like this:
  if (!user) {
    await bcrypt.hash('timing-attack-mitigation', 12);
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

Why does a dummy hash help?

bcrypt.hash() with a cost factor of 12 takes roughly the same time as bcrypt.compare(). By running one when the user is missing, the server takes the same amount of time regardless of whether the email exists.

The string 'timing-attack-mitigation' is arbitrary. Its only job is to burn the same CPU time that a real comparison would.

  • Save src/routes/auth.ts.
Test login and inspect the token

Time to verify that the login endpoint issues tokens correctly and that invalid credentials always produce the same error message.

  • Restart the server by pressing Ctrl+C and running:
node src/server.ts
  • Open a second terminal. First, register a test user by running:
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "secret123", "role": "editor"}'
  • Now test a successful login by running:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "secret123"}'

You should see a JSON response containing a token field with a long base64-encoded string (your JWT).

  • Test with a wrong email by running:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "wrong@example.com", "password": "secret123"}'
  • Test with a wrong password by running:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "wrongpassword"}'

Both failed attempts should return the identical response: {"error":"Authentication failed"}. An attacker cannot tell whether the email or the password was wrong.

Decode your JWT payload

A JWT has three base64-encoded parts separated by dots. You can decode the middle part (the payload) to see the claims. Copy the token value from your successful login and run:

echo 'PASTE_MIDDLE_PART_HERE' | base64 -d

You should see JSON with sub (the user's ID), role (editor), iat (issued-at timestamp), and exp (expiration timestamp).

Getting 'Cannot POST /auth/login'?

  • Make sure you restarted the server after saving your changes. The old process is still running the previous version.
  • Check that your login handler is outside the register handler's closing });. Nested handlers won't register as separate routes.
  • Verify the URL ends in /auth/login (not /login). The router is mounted at /auth in server.ts.

Help me debug my /auth/login endpoint

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/routes/auth.ts and your server restarts cleanly.

ⓧ I'd like to double check the full code

Here is the full src/routes/auth.ts file as it should look now:

import { Router } from 'express';
import bcrypt from 'bcrypt';
import { SignJWT } from 'jose';
import { randomUUID } from 'node:crypto';
import { users } from '../store.ts';
import { JWT_SECRET, ACCESS_TOKEN_EXPIRY } from '../config.ts';
import type { Role } from '../types.ts';

export const authRouter = Router();

authRouter.post('/register', async (req, res) => {
  const { email, password, role } = req.body;

  if (!email || !password) {
    res.status(400).json({ error: 'Email and password are required' });
    return;
  }

  const existingUser = [...users.values()].find(u => u.email === email);
  if (existingUser) {
    res.status(409).json({ error: 'Registration failed' });
    return;
  }

  const passwordHash = await bcrypt.hash(password, 12);
  const id = randomUUID();
  const validRoles: Role[] = ['viewer', 'editor', 'admin'];
  const userRole: Role = validRoles.includes(role) ? role : 'viewer';

  users.set(id, { id, email, passwordHash, role: userRole });

  res.status(201).json({ message: 'User registered', userId: id });
});

authRouter.post('/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    res.status(400).json({ error: 'Email and password are required' });
    return;
  }

  const user = [...users.values()].find(u => u.email === email);

  if (!user) {
    await bcrypt.hash('timing-attack-mitigation', 12);
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  const passwordMatch = await bcrypt.compare(password, user.passwordHash);
  if (!passwordMatch) {
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  const token = await new SignJWT({ sub: user.id, role: user.role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime(ACCESS_TOKEN_EXPIRY)
    .sign(JWT_SECRET);

  res.json({ token });
});

Your API can now register users and issue signed tokens on successful login. Next up, you'll build the middleware that verifies those tokens on every protected request.

Build Authentication Middleware

Your API can register users and issue tokens on login. But those tokens are useless until something actually checks them on incoming requests.

In this step, you'll build the middleware layer that sits between the client and your protected routes. Every request to a protected endpoint must carry a valid token or it gets rejected before the handler runs.

In this step, get ready to:
  • Build authentication middleware that verifies JWTs on every request.
  • Create protected routes that require a valid token to access.
  • Test that missing, tampered, and expired tokens are all rejected with 401.
Build the authenticate middleware

Middleware in Express is a function that runs before your route handler. It can inspect the request, modify it, or short-circuit it with an error response.

Your authenticate middleware will extract the Bearer token from the Authorization header, verify it with jose's jwtVerify(), and attach the decoded user info to the request object.

  • Create the src/middleware/ directory and the authenticate.ts file inside it by running these commands:
mkdir src/middleware
touch src/middleware/authenticate.ts
  • Confirm the file exists by running:
ls src/middleware/

You should see authenticate.ts in the output.

  • Add the following code to src/middleware/authenticate.ts:
import type { Request, Response, NextFunction } from 'express';
import { jwtVerify } from 'jose';
import { JWT_SECRET } from '../config.ts';

// Extend Express Request to include user info from the decoded token
export interface AuthenticatedRequest extends Request {
  user?: {
    sub: string;
    role: string;
  };
}

export async function authenticate(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  // Reject requests missing the Authorization header or wrong format
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Authentication required' });
    return;
  }

  // Extract just the token portion after "Bearer "
  const token = authHeader.slice(7);

  try {
    // Verify with algorithm pinned to HS256 (prevents alg:none attack)
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      algorithms: ['HS256'],
    });

    // Attach decoded claims so route handlers can use them
    req.user = {
      sub: payload.sub as string,
      role: payload.role as string,
    };

    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

What does this code do?

The AuthenticatedRequest interface extends Express's Request type to add an optional user property. This lets route handlers access the decoded token claims in a type-safe way.

The authenticate function checks for a Bearer token, verifies it, and either attaches the user info to the request (calling next() to proceed) or returns a 401 error.

Pinning algorithms: ['HS256'] tells jose to only accept tokens signed with HS256. This prevents an attacker from modifying the token header to use a different algorithm or stripping the signature entirely.

  • Save src/middleware/authenticate.ts.

You won't see a change yet. The next chunk creates routes that use this middleware.

Seeing a TypeScript error in your editor?

Make sure you used import type for the Express types (Request, Response, NextFunction). Node.js type stripping requires the type keyword for type-only imports.

Also check that your import path ends with .ts ('../config.ts'), as Node.js requires explicit extensions.

Help me fix a TypeScript import error in my authenticate middleware

Create protected routes and wire up the middleware

Now you need routes that actually use the middleware. Any request to these routes must carry a valid JWT or it gets rejected before the handler runs.

  • Create src/routes/protected.ts by running:
touch src/routes/protected.ts
  • Add the following code to src/routes/protected.ts:
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.ts';
import type { AuthenticatedRequest } from '../middleware/authenticate.ts';

export const protectedRouter = Router();

// Apply authenticate middleware to ALL routes on this router
protectedRouter.use(authenticate);

// Read posts - returns sample data for authenticated users
protectedRouter.get('/posts', (req: AuthenticatedRequest, res) => {
  res.json({
    posts: [
      { id: '1', title: 'Hello World', author: 'admin' },
      { id: '2', title: 'Getting Started', author: 'editor' },
    ],
  });
});

// Create a post - includes the authenticated user's ID
protectedRouter.post('/posts', (req: AuthenticatedRequest, res) => {
  res.status(201).json({ message: 'Post created', by: req.user?.sub });
});

// Delete a specific post by ID
protectedRouter.delete('/posts/:id', (req: AuthenticatedRequest, res) => {
  res.json({ message: `Post ${req.params.id} deleted`, by: req.user?.sub });
});

// Delete a specific user by ID
protectedRouter.delete('/users/:id', (req: AuthenticatedRequest, res) => {
  res.json({ message: `User ${req.params.id} deleted`, by: req.user?.sub });
});

What does this code do?

The protectedRouter.use(authenticate) line applies the authenticate middleware to every route registered on this router. No route handler runs unless the token passes verification first.

Each handler receives an AuthenticatedRequest, so it can access req.user?.sub (the user's ID from the token) and req.user?.role. In the next step, you'll add authorization middleware that checks the role before allowing access.

  • Save src/routes/protected.ts.

Now you need to mount this router on the main app so requests to /api are handled by it.

  • In src/server.ts, find this line near the top:
import { authRouter } from './routes/auth.ts';
  • Add the following import directly below it:
import { protectedRouter } from './routes/protected.ts';
  • Next, find this line:
app.use('/auth', authRouter);
  • Add the following line directly below it:
app.use('/api', protectedRouter);

Why mount at /api?

This means requests to /api/posts hit the protectedRouter's GET /posts handler. The /auth routes stay public (no token required), while all /api routes require authentication.

  • Save src/server.ts.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.ts

You should see Server running on http://localhost:3000 in the terminal.

Server won't start?

Check that src/routes/protected.ts exports protectedRouter (not a default export). The import in server.ts uses a named import.

Also verify that both import paths end with .ts and match the actual file locations exactly.

Help me debug why my server won't start after adding the protected router

✔️ Awesome, I've got everything!

Great. Double check you've saved both src/routes/protected.ts and src/server.ts.

ⓧ I'd like to double check the full code

Here is src/middleware/authenticate.ts:

import type { Request, Response, NextFunction } from 'express';
import { jwtVerify } from 'jose';
import { JWT_SECRET } from '../config.ts';

export interface AuthenticatedRequest extends Request {
  user?: {
    sub: string;
    role: string;
  };
}

export async function authenticate(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Authentication required' });
    return;
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      algorithms: ['HS256'],
    });

    req.user = {
      sub: payload.sub as string,
      role: payload.role as string,
    };

    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Here is src/routes/protected.ts:

import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.ts';
import type { AuthenticatedRequest } from '../middleware/authenticate.ts';

export const protectedRouter = Router();

protectedRouter.use(authenticate);

protectedRouter.get('/posts', (req: AuthenticatedRequest, res) => {
  res.json({
    posts: [
      { id: '1', title: 'Hello World', author: 'admin' },
      { id: '2', title: 'Getting Started', author: 'editor' },
    ],
  });
});

protectedRouter.post('/posts', (req: AuthenticatedRequest, res) => {
  res.status(201).json({ message: 'Post created', by: req.user?.sub });
});

protectedRouter.delete('/posts/:id', (req: AuthenticatedRequest, res) => {
  res.json({ message: `Post ${req.params.id} deleted`, by: req.user?.sub });
});

protectedRouter.delete('/users/:id', (req: AuthenticatedRequest, res) => {
  res.json({ message: `User ${req.params.id} deleted`, by: req.user?.sub });
});

Here is src/server.ts:

import express from 'express';
import { authRouter } from './routes/auth.ts';
import { protectedRouter } from './routes/protected.ts';

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

app.use('/auth', authRouter);
app.use('/api', protectedRouter);

app.get('/', (req, res) => {
  res.json({ message: 'Auth API is running' });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Test authenticated access

Time to prove that the middleware actually blocks unauthorized requests. You'll test with a valid token, with no token, and with a tampered token.

  • Register a user and log in to get a fresh token. Open a new terminal tab and run:
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret123","role":"editor"}'

You should see a response confirming the user was registered.

  • Log in to get a token by running:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"secret123"}'

You should see a JSON response with a token field. Copy that token value for the next commands.

  • Test a protected route with the valid token by running:
curl -s http://localhost:3000/api/posts \
  -H "Authorization: Bearer your-token-here"

Replace your-token-here with the actual token you copied from the login response. You should see a JSON response containing a posts array.

  • Test without a token by running:
curl -s http://localhost:3000/api/posts

You should see {"error":"Authentication required"} with a 401 status.

  • Test with a tampered token by taking your valid token, changing one character at the end, and running:
curl -s http://localhost:3000/api/posts \
  -H "Authorization: Bearer your-token-with-one-char-changed"

You should see {"error":"Invalid or expired token"}. The signature verification failed because the token was modified.

Why does a single character change break the token?

A JWT has three parts: header, payload, and signature. The signature is a cryptographic hash of the first two parts combined with the secret key. Changing even one character invalidates the signature, and jwtVerify() rejects it immediately.

Getting 401 even with a valid token?

Make sure you include Bearer (with a space after it) before the token in the Authorization header. The middleware checks for this exact prefix.

Also check that you copied the full token string from the login response. Tokens are long and easy to accidentally truncate.

Help me debug why my valid token is being rejected

Your authentication layer is now in place. Every /api route is protected by token verification. Next up, you'll add the authorization layer that checks whether an authenticated user has permission to perform a specific action.

Build RBAC Authorization

Your API now knows who is making each request. The authenticate middleware verifies the JWT and attaches the user's identity to the request object.

Knowing who someone is does not tell you what they're allowed to do. A viewer should be able to read posts but never delete users. In this step, you'll build the authorization layer that enforces a permission matrix based on the user's role.

In this step, get ready to:
  • Build the authorize middleware that checks role permissions.
  • Wire up authorize on all four protected routes.
  • Test the full RBAC flow with viewer, editor, and admin users.
Build the authorize middleware

The authorize middleware uses a pattern called a higher-order function. Instead of a single middleware, you write a function that returns a middleware. This lets you pass in the required permission as an argument.

  • Create a new file at src/middleware/authorize.ts and add the following code:
import type { Response, NextFunction } from 'express';
import type { AuthenticatedRequest } from './authenticate.ts';
import { ROLE_PERMISSIONS } from '../config.ts';
import type { Role } from '../types.ts';

export function authorize(requiredPermission: string) {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
    // Read the role that authenticate attached to the request
    const userRole = req.user?.role as Role | undefined;

    if (!userRole) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }

    // Look up this role's permissions in the matrix
    const permissions = ROLE_PERMISSIONS[userRole];

    // Reject if the role doesn't have the required permission
    if (!permissions || !permissions.includes(requiredPermission as any)) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }

    next();
  };
}

What does this code do?

The authorize function takes a permission string like 'posts:delete' and returns Express middleware. When a request arrives, the middleware reads the user's role (set by authenticate) and checks it against the ROLE_PERMISSIONS matrix from your config.

If the permission is missing, it returns 403 Forbidden with no details about which permission was required. This is defense in depth. An attacker learns nothing about what permissions exist.

Why a higher-order function?

Express middleware has a fixed signature: (req, res, next). You can't pass extra arguments directly. A higher-order function lets you "bake in" the permission string at route-definition time, producing a customized middleware for each endpoint.

  • Save src/middleware/authorize.ts.

Seeing a type error in your editor?

Make sure you're importing AuthenticatedRequest from ./authenticate.ts (relative path within the middleware folder), not from ../middleware/authenticate.ts.

Also check that your imports use import type for type-only imports. Node.js type stripping requires this.

help me fix a type error in my authorize middleware

Apply authorization to protected routes

With the middleware built, you can now add permission checks to each route. The authenticate middleware runs first (via router.use(authenticate)), then authorize checks the specific permission for each endpoint.

  • In src/routes/protected.ts, find this import block at the top of the file:
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.ts';
import type { AuthenticatedRequest } from '../middleware/authenticate.ts';
  • Add the authorize import so the block looks like this:
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.ts';
import { authorize } from '../middleware/authorize.ts';
import type { AuthenticatedRequest } from '../middleware/authenticate.ts';
  • Now find the four route handlers below protectedRouter.use(authenticate); and replace them with these versions that include the authorize middleware:
protectedRouter.get('/posts', authorize('posts:read'), (req: AuthenticatedRequest, res) => {
  res.json({
    posts: [
      { id: '1', title: 'Hello World', author: 'admin' },
      { id: '2', title: 'Getting Started', author: 'editor' },
    ],
  });
});

protectedRouter.post('/posts', authorize('posts:create'), (req: AuthenticatedRequest, res) => {
  res.status(201).json({ message: 'Post created', by: req.user?.sub });
});

protectedRouter.delete('/posts/:id', authorize('posts:delete'), (req: AuthenticatedRequest, res) => {
  res.json({ message: `Post ${req.params.id} deleted`, by: req.user?.sub });
});

protectedRouter.delete('/users/:id', authorize('users:delete'), (req: AuthenticatedRequest, res) => {
  res.json({ message: `User ${req.params.id} deleted`, by: req.user?.sub });
});

What does this code do?

Each route now has two middleware layers. First authenticate verifies the token (applied via router.use). Then authorize checks the specific permission for that endpoint.

The permission strings follow the resource:action convention. This makes the RBAC matrix readable. You can see at a glance exactly what capability each route requires.

  • Save src/routes/protected.ts.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.ts

You should see Server running on http://localhost:3000 in the terminal output.

Server crashes on startup?

Check that your import path in protected.ts uses ../middleware/authorize.ts (with the .ts extension). Node.js type stripping requires explicit file extensions on relative imports.

help me fix a server crash after adding authorize middleware

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete src/middleware/authorize.ts:

import type { Response, NextFunction } from 'express';
import type { AuthenticatedRequest } from './authenticate.ts';
import { ROLE_PERMISSIONS } from '../config.ts';
import type { Role } from '../types.ts';

export function authorize(requiredPermission: string) {
  return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
    const userRole = req.user?.role as Role | undefined;

    if (!userRole) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }

    const permissions = ROLE_PERMISSIONS[userRole];

    if (!permissions || !permissions.includes(requiredPermission as any)) {
      res.status(403).json({ error: 'Forbidden' });
      return;
    }

    next();
  };
}

Here is the complete src/routes/protected.ts:

import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.ts';
import { authorize } from '../middleware/authorize.ts';
import type { AuthenticatedRequest } from '../middleware/authenticate.ts';

export const protectedRouter = Router();

protectedRouter.use(authenticate);

protectedRouter.get('/posts', authorize('posts:read'), (req: AuthenticatedRequest, res) => {
  res.json({
    posts: [
      { id: '1', title: 'Hello World', author: 'admin' },
      { id: '2', title: 'Getting Started', author: 'editor' },
    ],
  });
});

protectedRouter.post('/posts', authorize('posts:create'), (req: AuthenticatedRequest, res) => {
  res.status(201).json({ message: 'Post created', by: req.user?.sub });
});

protectedRouter.delete('/posts/:id', authorize('posts:delete'), (req: AuthenticatedRequest, res) => {
  res.json({ message: `Post ${req.params.id} deleted`, by: req.user?.sub });
});

protectedRouter.delete('/users/:id', authorize('users:delete'), (req: AuthenticatedRequest, res) => {
  res.json({ message: `User ${req.params.id} deleted`, by: req.user?.sub });
});
Test the full RBAC flow

Time to prove the permission matrix works. You'll register three users with different roles, login as each, and verify that each role can only access what the matrix allows.

  • Open a second terminal and register three users by running these commands:
curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"viewer@test.com","password":"pass123","role":"viewer"}'

curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"editor@test.com","password":"pass123","role":"editor"}'

curl -s -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@test.com","password":"pass123","role":"admin"}'

What should I see?

Each command returns a 201 response with {"message":"User registered","userId":"..."}.

  • Login as the viewer and save the token by running:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"viewer@test.com","password":"pass123"}'
  • Copy the token value from the response. Store it in a shell variable by running:
VIEWER_TOKEN=[[VIEWER_TOKEN="paste the viewer token here"]]
  • Repeat for editor and admin:
curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"editor@test.com","password":"pass123"}'

curl -s -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@test.com","password":"pass123"}'
  • Store each token in shell variables:
EDITOR_TOKEN=[[EDITOR_TOKEN="paste the editor token here"]]
ADMIN_TOKEN=[[ADMIN_TOKEN="paste the admin token here"]]
  • Test the viewer role. Try reading posts (should succeed) and creating a post (should fail) by running:
curl -s http://localhost:3000/api/posts \
  -H "Authorization: Bearer $VIEWER_TOKEN"

curl -s -X POST http://localhost:3000/api/posts \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H "Content-Type: application/json"

The first command returns the posts array. The second returns {"error":"Forbidden"} with no details about which permission was missing.

  • Test the editor role. Try creating a post (should succeed) and deleting a post (should fail) by running:
curl -s -X POST http://localhost:3000/api/posts \
  -H "Authorization: Bearer $EDITOR_TOKEN" \
  -H "Content-Type: application/json"

curl -s -X DELETE http://localhost:3000/api/posts/1 \
  -H "Authorization: Bearer $EDITOR_TOKEN"

The first command returns 201 with a success message. The second returns {"error":"Forbidden"}.

  • Test the admin role. Try deleting a post and deleting a user (both should succeed) by running:
curl -s -X DELETE http://localhost:3000/api/posts/1 \
  -H "Authorization: Bearer $ADMIN_TOKEN"

curl -s -X DELETE http://localhost:3000/api/users/1 \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Both commands return success messages. The admin has full access across all endpoints.

What is RBAC?

Role-based access control (RBAC) maps each user role to a set of allowed permissions. Instead of checking individual users, you check their role against a permission matrix. This scales cleanly because adding a new user means assigning a role, not editing every route.

Getting 401 instead of 403?

A 401 means the token itself is invalid or expired. Tokens expire after 15 minutes. If you took a break, login again to get a fresh token.

Also check that your shell variable is set correctly. Run echo $VIEWER_TOKEN to confirm it contains the token string.

help me debug why I'm getting 401 instead of 403 on my RBAC tests

Your API now has a complete security model. Authentication verifies identity, and authorization enforces what each identity is allowed to do. Next up, you'll tackle the Secret Mission and solve the hardest problem with stateless tokens: revocation.

Secret mission

Refresh Token Rotation with Reuse Detection

Your access tokens expire after 15 minutes. What happens when a legitimate user's token expires mid-session? And what if an attacker steals a refresh token? Add refresh token rotation with reuse detection to solve the stateless JWT revocation problem.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future reference, stop the running server, or delete everything entirely.

Resources you used:

  • The auth-api project folder on your Desktop (source files, config, and node_modules/).
  • A local Node.js server process (only runs while your terminal session is active).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the API or extending it further.

  • Your project files stay in the auth-api folder on your Desktop.
  • The server only runs when you explicitly start it with node src/server.ts, so there is nothing consuming resources in the background.

Pause - I'll come back to this later

Stop the running server to free up the terminal. Your files remain on disk for when you return.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server process.
  • Your project files stay in place. Start the server again anytime with node src/server.ts from inside the auth-api folder.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything you built in this project.

  • Stop the server if it is still running by pressing Ctrl+C in the terminal.
  • Delete the project folder by running this command from your Desktop:
cd ~/Desktop
rm -rf auth-api
  • On Windows, use this command instead:
cd ~/Desktop
Remove-Item -Recurse -Force auth-api
  • Confirm the folder is gone by running ls and checking that auth-api no longer appears.

Nice Work!

Nice Work!

Nice work! You just built a production-style authentication and authorization API from scratch using Express, TypeScript, and security-first principles.

You've learned how to:

  • Build a complete authentication system with bcrypt password hashing, timing attack mitigation, and short-lived JWT access tokens.
  • Build layered middleware that separates authentication (who are you?) from authorization (what can you do?).
  • Implement a role-permission matrix (RBAC) that enforces fine-grained access control across API endpoints.
  • Secret Mission: Added refresh token rotation with reuse detection, solving the stateless JWT revocation problem.

Ready to quiz yourself?