JWT Authentication for Your API

Add JWT auth, password hashing, and user-scoped data to your API.

Introduction

30 Second Summary

Imagine a shared lab notebook where anyone can read or edit anyone else's entries. Without knowing who wrote what, there's no accountability and no privacy.

In this project, you will add JWT-based authentication to your lab notebook API so that every experiment belongs to a specific user and every request requires a valid token.

What You'll Build

A complete authentication layer for your bioengineering lab notebook API, including user registration, login with hashed passwords, and token-protected routes.

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

  • A users table in PostgreSQL with passwords hashed using bcrypt before they ever touch the database.
  • A requireAuth middleware that validates JSON Web Tokens on every protected route and attaches the user's identity to each request.
  • User-scoped experiment CRUD so each researcher can only access their own data.
  • Secret Mission: Add refresh token rotation with a dedicated database table and reuse detection, matching production OAuth 2.0 patterns.

Are there any prerequisites?

Yes. This project is Part 2 of the Lab Notebook API series. You need a working Express 5 + Drizzle ORM + PostgreSQL API with an experiments table and CRUD endpoints from Part 1.

You should also have VS Code or Cursor, Node.js, and TypeScript set up from that project.

Before We Start

Your lab notebook API currently lets anyone create, read, update, and delete experiments. There is no concept of a user, and anyone who can reach the server can see every record in the database.

In this step, you will install the authentication libraries your API needs. You will also set up environment variables to store secrets safely outside your source code.

In this step, get ready to:
  • Install bcryptjs for password hashing and jsonwebtoken for token creation.
  • Create a .env file with a strong secret for signing tokens.
  • Verify TypeScript compiles cleanly with the new packages.
Install the auth packages

Your API needs two libraries to handle authentication. bcrypt is a password-hashing algorithm that turns plaintext passwords into irreversible hashes. JSON Web Tokens (JWTs) are compact, signed strings that prove a user's identity on every request.

  • Install the core auth packages by running this command:
npm install bcryptjs jsonwebtoken
  • Install the TypeScript type definitions as dev dependencies by running:
npm install -D @types/bcryptjs @types/jsonwebtoken

What are these packages?

bcryptjs is a pure JavaScript implementation of the bcrypt algorithm. It hashes passwords so you never store them in plain text.

jsonwebtoken lets you create and verify signed tokens. Your API will issue these tokens when a user logs in, then check them on every protected request.

The @types/ packages provide TypeScript definitions so your editor can autocomplete and type-check these libraries.

  • Both commands should print a summary showing packages were added, with no error text.

Seeing network errors?

If you see ENETUNREACH or ERR_SOCKET_TIMEOUT, check your internet connection and retry. If you see permission errors, try running the command with sudo on macOS/Linux or an elevated terminal on Windows.

Help me debug my npm install error

Create a .env file for JWT secrets

Hardcoding secrets in source code is a security risk. Environment variables keep sensitive values out of version control and let you change them without editing code.

  • Generate a random 64-character hex string for your token secret by running:
openssl rand -hex 32

Why 64 characters?

openssl rand -hex 32 generates 32 random bytes and encodes them as 64 hexadecimal characters. This gives your signing key enough entropy to resist brute-force attacks.

  • Copy the output string. You will paste it into your .env file in the next step.
  • Create a new file called .env in the root of your project folder (the same level as package.json). Paste this content into it:
DATABASE_URL=postgresql://your_user:your_password@localhost:5432/lab_notebook
ACCESS_TOKEN_SECRET=your-generated-64-character-hex-string-here

What do these variables do?

DATABASE_URL tells Drizzle ORM how to connect to your PostgreSQL database. Replace your_user and your_password with the credentials you used in Part 1.

ACCESS_TOKEN_SECRET is the key your API uses to sign and verify JWTs. Replace the placeholder with the hex string you generated above.

  • Save .env.
  • Install the dotenv package so your app can read these variables at startup:
npm install dotenv
  • In src/index.ts, find the existing imports at the top of the file:
import express from "express";
import experimentsRouter from "./routes/experiments.js";
  • Add import "dotenv/config"; as the very first line above the existing imports, so the top of your file now looks like this:
import "dotenv/config";
import express from "express";
import experimentsRouter from "./routes/experiments.js";

What does this import do?

import "dotenv/config" reads your .env file and loads its values into process.env as soon as the module is imported. Placing it first ensures every other module in your app can access the variables.

  • Save src/index.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved both .env and src/index.ts.

ⓧ I'd like to double check the full code

Here is the full .env file:

DATABASE_URL=postgresql://your_user:your_password@localhost:5432/lab_notebook
ACCESS_TOKEN_SECRET=replace-this-with-a-64-character-random-string-generate-with-openssl-rand-hex-32

Here is the full src/index.ts file:

import "dotenv/config";
import express from "express";
import experimentsRouter from "./routes/experiments.js";

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use("/experiments", experimentsRouter);

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Seeing 'Cannot find module dotenv/config'?

Make sure you ran npm install dotenv and that dotenv appears in your package.json dependencies. Also confirm the import uses a string literal with no typos.

Help me fix my dotenv import error

Verify the setup compiles

Before moving on, you need to confirm TypeScript can resolve the types for both new libraries. A quick compilation check catches missing or broken type definitions now instead of later.

  • Create a temporary scratch file to test the imports by running:
touch src/test-imports.ts
  • Open src/test-imports.ts and paste this code:
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";

// Quick type check - these should resolve without errors
console.log(bcrypt.hash, jwt.sign);
  • Save src/test-imports.ts, then run the TypeScript compiler in check mode:
npx tsc --noEmit

What does --noEmit do?

The --noEmit flag tells TypeScript to check your code for type errors without producing any JavaScript output files. It is a fast way to validate that all imports and types resolve correctly.

  • You should see no output at all. A clean return to the command prompt means zero type errors.
  • Delete the scratch file now that you have confirmed the types work:
rm src/test-imports.ts

Seeing type errors?

If TypeScript reports errors about bcryptjs or jsonwebtoken, confirm you installed the type packages with the -D flag. Run npm install -D @types/bcryptjs @types/jsonwebtoken again and retry.

If you see errors about your existing project files (not the scratch file), those are pre-existing issues from Part 1. Focus only on whether the two new imports resolve cleanly.

Help me fix my TypeScript compilation errors

Your auth dependencies are installed and TypeScript is happy. Next up, you will create a users table and link it to your experiments so every record belongs to someone.

Add the Users Table and Link Experiments

Your auth dependencies are installed and your environment is configured. Now it's time to give your database the structure it needs to support user accounts.

Right now, experiments float freely in the database with no owner. In this step, you'll create a users table and link every experiment to a specific user through a foreign key.

In this step, get ready to:
  • Define a users table with email, hashed password, and timestamps.
  • Add a userId foreign key to the experiments table.
  • Generate and apply the database migration.
Define the users table in the Drizzle schema

Before you can register or authenticate anyone, your database needs a place to store user credentials. The users table holds emails, hashed passwords, and timestamps for every account.

  • Open src/db/schema.ts in your editor.
  • Add the following users table definition above the existing experiments table:
export const users = pgTable("users", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  email: varchar({ length: 255 }).notNull().unique(),
  password: varchar({ length: 255 }).notNull(),
  createdAt: timestamp().notNull().defaultNow(),
  updatedAt: timestamp().notNull().defaultNow(),
});

What does this code do?

This defines a PostgreSQL table called users using Drizzle ORM's schema builder.

  • id is an auto-incrementing primary key (same pattern as your experiments table).
  • email has a unique() constraint so no two users can register with the same address.
  • password stores the bcrypt hash, not the raw password. The hash can be up to 60 characters, but 255 gives headroom for future algorithms.

You won't see a visible change yet. The next substep modifies the experiments table, and then you'll run the migration to apply both changes at once.

Add a userId foreign key to the experiments table

Every experiment needs to belong to someone. A foreign key column links each experiment row back to the user who created it.

  • In src/db/schema.ts, find the experiments table definition.
  • Add the following userId column just above the createdAt line:
  userId: integer()
    .notNull()
    .references(() => users.id),

What does this column do?

The .references(() => users.id) call creates a foreign key constraint in PostgreSQL. This guarantees that every experiment points to a valid user. If you try to insert an experiment with a userId that doesn't exist in the users table, the database rejects it.

  • Now add the user type exports at the bottom of the file, after the existing experiment type exports:
export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;

What are these types for?

Drizzle's $inferInsert and $inferSelect generate TypeScript types that match the table's columns. You'll use InsertUser when creating new users and SelectUser when reading them back from the database.

  • Save src/db/schema.ts.
  • Confirm TypeScript is happy by running:
npx tsc --noEmit

You should see no errors in the output. If TypeScript reports issues, double check that users is defined above experiments in the file (since experiments references users).

Seeing a type error?

  • Make sure the users table is defined before the experiments table. The .references(() => users.id) call needs users to already be declared.
  • Check that your import at the top includes integer, pgTable, varchar, text, and timestamp from drizzle-orm/pg-core.

Help me debug my Drizzle schema type error

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that TypeScript compiled without errors.

ⓧ I'd like to double check the full code

Here's the complete src/db/schema.ts file:

import { integer, pgTable, varchar, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  email: varchar({ length: 255 }).notNull().unique(),
  password: varchar({ length: 255 }).notNull(),
  createdAt: timestamp().notNull().defaultNow(),
  updatedAt: timestamp().notNull().defaultNow(),
});

export const experiments = pgTable("experiments", {
  id: integer().primaryKey().generatedAlwaysAsIdentity(),
  title: varchar({ length: 225 }).notNull(),
  hypothesis: text(),
  materials: text(),
  protocol: text(),
  observations: text(),
  results: text(),
  status: varchar({ length: 20 }).notNull().default("planned"),
  userId: integer()
    .notNull()
    .references(() => users.id),
  createdAt: timestamp().notNull().defaultNow(),
  updatedAt: timestamp().notNull().defaultNow(),
});

export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect;
export type InsertExperiment = typeof experiments.$inferInsert;
export type SelectExperiment = typeof experiments.$inferSelect;
Run the migration

Your schema file defines the desired state of your database. Drizzle Kit compares this to what actually exists in PostgreSQL and generates the SQL migration to bridge the gap.

  • Generate the migration by running:
npx drizzle-kit generate

What does this command do?

This reads your schema file and generates a SQL migration file in your migrations folder. The migration contains CREATE TABLE for the new users table and ALTER TABLE to add the userId column and foreign key to experiments.

  • Apply the migration to your database by running:
npx drizzle-kit migrate
  • Verify the tables exist by opening psql and running:
psql -d lab_notebook -c "\d users"

You should see the users table with columns for id, email, password, created_at, and updated_at. The email column should show a unique constraint.

Migration failed?

  • If you see a connection error, make sure your PostgreSQL service is running and the DATABASE_URL in your .env file is correct.
  • If the migration fails because the experiments table already has data, you'll need to either drop the table first or add userId as nullable initially. For this project, dropping and recreating is simplest since it's a development database.

Help me fix my Drizzle migration error

Why does users need to exist before experiments?

In both the schema file and the database, the users table must be defined first because experiments.userId references users.id. PostgreSQL won't let you create a foreign key pointing to a table that doesn't exist yet.

Your database schema is ready with both tables linked together. Next up, you'll build the register and login endpoints that create users and issue access tokens.

Build Register and Login Endpoints

Your database now has a users table with hashed password storage and an experiments table linked to users via a foreign key. The schema is ready, but there is no way for anyone to actually create an account or prove who they are.

In this step, you will build two endpoints that handle the full authentication flow. The register endpoint hashes passwords with bcrypt before storing them, and the login endpoint issues a short-lived JSON Web Token that proves the user's identity on future requests.

In this step, get ready to:
  • Create a register endpoint that validates input, hashes passwords, and stores new users.
  • Create a login endpoint that verifies credentials and issues a JWT access token.
  • Mount the auth router and test both endpoints with curl.
Create the auth route file with the register endpoint

The register endpoint needs to do three things: validate the incoming email and password, hash the password so it is never stored in plain text, and insert the new user into the database.

  • Create a new file at src/routes/auth.ts and add the following imports, router setup, and validation schemas:
import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";

const router = Router();

// Read the secret from environment variables
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

// Zod schema for registration: valid email and min 8-character password
const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

// Zod schema for login: valid email and non-empty password
const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

export default router;

What does this code do?

This file scaffold sets up everything the register and login handlers will share.

  • bcryptjs provides password hashing functions. jsonwebtoken signs and verifies JWTs.
  • ACCESS_TOKEN_SECRET is the signing key loaded from your .env file. The ! tells TypeScript you guarantee it exists at runtime.
  • The two Zod schemas validate request bodies before any database work happens.

You won't see a change yet. The next chunk adds the register handler that uses these schemas.

  • In src/routes/auth.ts, find the line export default router; and add the following register handler above it:
router.post("/register", async (req: Request, res: Response) => {
  // Validate input against the register schema
  const parsed = registerSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const { email, password } = parsed.data;
  // Hash the password with 10 salt rounds before storing
  const hashedPassword = await bcrypt.hash(password, 10);

  try {
    // Insert user and return only non-sensitive fields
    const [user] = await db
      .insert(users)
      .values({ email, password: hashedPassword })
      .returning({ id: users.id, email: users.email, createdAt: users.createdAt });

    res.status(201).json({ user });
  } catch (error: any) {
    // PostgreSQL error 23505 = unique constraint violation (duplicate email)
    if (error?.code === "23505") {
      res.status(409).json({ error: "Email already registered" });
      return;
    }
    throw error;
  }
});

What does this code do?

The register handler follows a secure pattern: validate first, hash second, store third.

  • bcrypt.hash(password, 10) runs 10 rounds of the Blowfish algorithm to produce a one-way hash. The plain password never touches the database.
  • .returning() asks PostgreSQL to send back the new row, but only the safe fields (id, email, createdAt). The hashed password stays server-side.
  • If someone tries to register with an email that already exists, PostgreSQL throws error code 23505 (unique constraint violation). The catch block converts that into a clear 409 Conflict response.
  • Save src/routes/auth.ts.
  • Confirm TypeScript is happy by running:
npx tsc --noEmit

You should see no output (no errors means success).

Seeing type errors?

  • If TypeScript can't find bcryptjs or jsonwebtoken types, confirm you installed @types/bcryptjs and @types/jsonwebtoken in Step 1.
  • If db or users can't be resolved, check that your import paths end with .js (required for ESM resolution in TypeScript).

help me fix TypeScript errors in my auth route file

Add the login endpoint

The login endpoint looks up a user by email, compares the submitted password against the stored hash, and issues a signed JWT if everything checks out. The token carries the user's ID in its sub claim and expires after 15 minutes.

  • In src/routes/auth.ts, add the login handler below the register handler (still above export default router;):
router.post("/login", async (req: Request, res: Response) => {
  // Validate input against the login schema
  const parsed = loginSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const { email, password } = parsed.data;

  // Look up user by email
  const [user] = await db.select().from(users).where(eq(users.email, email));

  if (!user) {
    // Generic message so attackers can't enumerate valid emails
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  // Compare submitted password against stored bcrypt hash
  const passwordMatch = await bcrypt.compare(password, user.password);

  if (!passwordMatch) {
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  // Sign a short-lived access token with the user's ID
  const accessToken = jwt.sign({ sub: user.id }, ACCESS_TOKEN_SECRET, {
    algorithm: "HS256",
    expiresIn: "15m",
  });

  res.status(200).json({
    accessToken,
    user: { id: user.id, email: user.email },
  });
});

What does this code do?

The login handler verifies identity and issues proof of authentication.

  • bcrypt.compare() checks the plain password against the stored hash without ever decrypting it. Bcrypt handles the salt extraction internally.
  • jwt.sign() creates a token with sub: user.id as the payload. The HS256 algorithm is explicitly pinned to prevent downgrade attacks.
  • Both the "user not found" and "wrong password" cases return the same generic Invalid credentials message. This prevents attackers from discovering which emails are registered.

Why pin the algorithm to HS256?

JWT libraries support multiple algorithms. If you don't specify one, an attacker could craft a token with alg: "none" in the header, bypassing signature verification entirely.

Pinning algorithm: "HS256" on sign and algorithms: ["HS256"] on verify (which you will add in the next step) ensures only your chosen algorithm is accepted.

  • Save src/routes/auth.ts.
  • Confirm there are no type errors by running:
npx tsc --noEmit

No output means no errors.

Getting a type error on jwt.sign?

  • Make sure ACCESS_TOKEN_SECRET has the non-null assertion (!) at the end of the line. Without it, TypeScript sees string | undefined which jwt.sign does not accept.
  • Check that @types/jsonwebtoken is installed as a dev dependency. Run npm ls @types/jsonwebtoken to confirm.

help me fix a type error with jwt.sign in my auth route

✔️ Awesome, I've got everything!

Great. Double check you have saved src/routes/auth.ts with both the register and login handlers.

ⓧ I'd like to double check the full code

import { Router, Request, Response } from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { z } from "zod";
import { db } from "../db/index.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";

const router = Router();

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});

router.post("/register", async (req: Request, res: Response) => {
  const parsed = registerSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const { email, password } = parsed.data;
  const hashedPassword = await bcrypt.hash(password, 10);

  try {
    const [user] = await db
      .insert(users)
      .values({ email, password: hashedPassword })
      .returning({ id: users.id, email: users.email, createdAt: users.createdAt });

    res.status(201).json({ user });
  } catch (error: any) {
    if (error?.code === "23505") {
      res.status(409).json({ error: "Email already registered" });
      return;
    }
    throw error;
  }
});

router.post("/login", async (req: Request, res: Response) => {
  const parsed = loginSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const { email, password } = parsed.data;

  const [user] = await db.select().from(users).where(eq(users.email, email));

  if (!user) {
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  const passwordMatch = await bcrypt.compare(password, user.password);

  if (!passwordMatch) {
    res.status(401).json({ error: "Invalid credentials" });
    return;
  }

  const accessToken = jwt.sign({ sub: user.id }, ACCESS_TOKEN_SECRET, {
    algorithm: "HS256",
    expiresIn: "15m",
  });

  res.status(200).json({
    accessToken,
    user: { id: user.id, email: user.email },
  });
});

export default router;
Mount the auth router in the main app

The auth file exports a router, but Express does not know about it yet. You need to import it and mount it at the /auth path.

  • In src/index.ts, find this import line:
import experimentsRouter from "./routes/experiments.js";
  • Add the auth router import directly above it, so both imports look like this:
import authRouter from "./routes/auth.js";
import experimentsRouter from "./routes/experiments.js";
  • Now find this line further down:
app.use("/experiments", experimentsRouter);
  • Add the auth mount directly above it:
app.use("/auth", authRouter);
app.use("/experiments", experimentsRouter);
  • Save src/index.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.ts with the new auth router import and mount.

ⓧ I'd like to double check the full code

import "dotenv/config";
import express from "express";
import authRouter from "./routes/auth.js";
import experimentsRouter from "./routes/experiments.js";

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.use("/auth", authRouter);
app.use("/experiments", experimentsRouter);

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Now test both endpoints. Start your dev server by running:

  • Start the server by running:
npx tsx src/index.ts

You should see Server running on port 3000 in the terminal.

  • Open a new terminal tab and test the register endpoint by running:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "scientist@lab.io", "password": "securepass123"}'

You should see a JSON response with status 201 containing the new user's id, email, and createdAt fields.

  • Test the login endpoint by running:
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "scientist@lab.io", "password": "securepass123"}'

You should see a JSON response with an accessToken string (a long dot-separated JWT) and a user object with id and email.

Getting connection refused or 404?

  • If the server didn't start, check the first terminal for errors. A common cause is a missing .env file or unresolved imports.
  • If you see a 404, confirm you mounted the router at /auth (not / or /api) and that your curl URL includes /auth/register or /auth/login.
  • If you see a 400 validation error, ensure the JSON body has both email (valid format) and password (at least 8 characters for register).

help me debug my auth endpoints

Your authentication endpoints are live. Next up, you will build a middleware that reads the JWT from incoming requests and blocks anyone who is not logged in.

Protect Routes with requireAuth Middleware

Your register and login endpoints are working. But the experiment routes are still wide open. Anyone can create, read, update, or delete experiments without logging in.

In this step, you will build a reusable middleware function that checks for a valid JWT in every request. Then you will apply it to every experiment route so that each user can only see their own data.

In this step, get ready to:
  • Create TypeScript type augmentation so the Request object knows about userId.
  • Build a reusable requireAuth middleware that validates JWTs.
  • Scope all experiment queries to the authenticated user.
Create TypeScript declaration merging for the Request type

Your middleware will attach a userId property to the Express Request object after verifying the token. TypeScript does not know about this custom property by default, so you need to tell it.

  • Create a new folder called types inside your src directory.
  • Inside src/types, create a new file called express.d.ts and add the following code:
declare namespace Express {
  interface Request {
    userId?: number;
  }
}

What is declaration merging?

TypeScript lets you "merge" new properties into existing interfaces. The Express type definitions expose an open Express.Request interface specifically for this purpose.

By declaring userId?: number as optional, TypeScript will not complain in routes where the middleware has not run yet. After the middleware sets it, you access it safely with the non-null assertion req.userId! in protected routes.

  • Save src/types/express.d.ts.

TypeScript not recognizing your new property?

Make sure the file is named express.d.ts (with the .d.ts extension), not .ts. A regular .ts file that contains import or export statements becomes a module, which breaks the global namespace augmentation.

Also confirm the file has no import or export statements. It must remain a plain ambient declaration file.

Why isn't my userId property showing up?

Build the requireAuth middleware

The middleware intercepts every request before it reaches a route handler. It extracts the token from the Authorization header, verifies the signature, and attaches the user's ID to the request.

  • Create a new folder called middleware inside your src directory.
  • Inside src/middleware, create a new file called auth.ts and add the following code:
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;

  if (!header?.startsWith("Bearer ")) {
    res.status(401).json({ error: "Missing bearer token" });
    return;
  }

  const token = header.slice(7);

  try {
    const payload = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      algorithms: ["HS256"],
    }) as { sub: number };

    req.userId = payload.sub;
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
    return;
  }
}

What does this middleware do?

  • header?.startsWith("Bearer ") checks that the client sent an Authorization header in the correct format.
  • header.slice(7) strips the "Bearer " prefix (7 characters) to get the raw token string.
  • jwt.verify checks the token's signature and expiry. The algorithms: ["HS256"] option pins the allowed algorithm. Without this, an attacker could forge a token using the "none" algorithm.
  • On success, req.userId = payload.sub attaches the user's ID to the request so route handlers can use it.

Why pin the algorithm to HS256?

JWT tokens include a header that declares which algorithm was used to sign them. If you let jwt.verify accept any algorithm, an attacker can craft a token with alg: "none" and bypass signature verification entirely.

By explicitly passing algorithms: ["HS256"], you tell the library to reject any token that was not signed with HMAC-SHA256. This is a critical security best practice.

  • Save src/middleware/auth.ts.
  • Confirm TypeScript is happy by running:
npx tsc --noEmit
  • You should see no errors in the terminal output.

Seeing a type error on jwt.verify?

Make sure you installed @types/jsonwebtoken as a dev dependency in Step 1. Without it, TypeScript cannot find type definitions for the jsonwebtoken package.

Also check that your import jwt from "jsonwebtoken" uses the default import. Named imports like { verify } work too, but the rest of this project uses the default.

Help me fix a TypeScript error with jwt.verify

Apply the middleware and scope experiments to the user

Now you will update the experiments router to require authentication on every route. Each query will filter by the authenticated user's ID so that users can never see or modify each other's experiments.

  • Open src/routes/experiments.ts and replace its entire contents with the following code:
import { Router, Request, Response } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { experiments } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth } from "../middleware/auth.js";

const router = Router();

router.use(requireAuth);

const createExperimentSchema = z.object({
  title: z.string().min(1).max(225),
  hypothesis: z.string().optional(),
  materials: z.string().optional(),
  protocol: z.string().optional(),
  observations: z.string().optional(),
  results: z.string().optional(),
  status: z.enum(["planned", "in-progress", "completed"]).optional(),
});

const updateExperimentSchema = createExperimentSchema.partial();

// GET all experiments for the authenticated user
router.get("/", async (req: Request, res: Response) => {
  const userExperiments = await db
    .select()
    .from(experiments)
    .where(eq(experiments.userId, req.userId!));

  res.status(200).json(userExperiments);
});

// GET a single experiment by ID (only if owned by user)
router.get("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const [experiment] = await db
    .select()
    .from(experiments)
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)));

  if (!experiment) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(experiment);
});

// POST create a new experiment
router.post("/", async (req: Request, res: Response) => {
  const parsed = createExperimentSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const [experiment] = await db
    .insert(experiments)
    .values({ ...parsed.data, userId: req.userId! })
    .returning();

  res.status(201).json(experiment);
});

// PATCH update an experiment
router.patch("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const parsed = updateExperimentSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const [updated] = await db
    .update(experiments)
    .set({ ...parsed.data, updatedAt: new Date() })
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)))
    .returning();

  if (!updated) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(updated);
});

// DELETE an experiment
router.delete("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const [deleted] = await db
    .delete(experiments)
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)))
    .returning();

  if (!deleted) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(deleted);
});

export default router;

What changed in the experiments router?

  • router.use(requireAuth) applies the middleware to every route in this file. No request reaches a handler without a valid token.
  • The and import from drizzle-orm lets you combine two conditions in a WHERE clause (match the experiment ID AND the user ID).
  • GET / now filters by eq(experiments.userId, req.userId!) so users only see their own experiments.
  • POST / injects userId: req.userId! into the insert values, linking each new experiment to the logged-in user.
  • PATCH and DELETE add eq(experiments.userId, req.userId!) to the WHERE clause. If the experiment belongs to a different user, the query returns nothing and the handler responds with 404.
  • Save src/routes/experiments.ts.
  • Start your server by running:
npx tsx src/index.ts
  • Test that unauthenticated requests are blocked by running:
curl http://localhost:3000/experiments
  • You should see a 401 response: {"error":"Missing bearer token"}.
  • Now test the full authenticated flow. Register a user by running:
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"scientist@lab.io","password":"securepass123"}'
  • Log in to get an access token by running:
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"scientist@lab.io","password":"securepass123"}'
  • Copy the accessToken value from the response.
  • Use it to create an experiment by running:
curl -X POST http://localhost:3000/experiments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-access-token-here" \
  -d '{"title":"CRISPR Gene Edit Trial","status":"planned"}'
  • Replace your-access-token-here with the actual token from the login response.
  • You should see a 201 response with your new experiment, including the userId field matching the logged-in user.

Getting 401 even with a token?

Check that your Authorization header starts with Bearer (capital B, followed by a space). The middleware checks for this exact prefix.

If you see "Invalid or expired token", your token may have expired (they last 15 minutes). Log in again to get a fresh one.

Also confirm that the ACCESS_TOKEN_SECRET in your .env file has not changed between login and the experiments request. If the secret differs, verification will always fail.

Help me debug my 401 error with JWT tokens

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: src/types/express.d.ts, src/middleware/auth.ts, and src/routes/experiments.ts.

ⓧ I'd like to double check the full code

Here is the complete code for each file created or modified in this step.

src/types/express.d.ts:

declare namespace Express {
  interface Request {
    userId?: number;
  }
}

src/middleware/auth.ts:

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!;

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization;

  if (!header?.startsWith("Bearer ")) {
    res.status(401).json({ error: "Missing bearer token" });
    return;
  }

  const token = header.slice(7);

  try {
    const payload = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      algorithms: ["HS256"],
    }) as { sub: number };

    req.userId = payload.sub;
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
    return;
  }
}

src/routes/experiments.ts:

import { Router, Request, Response } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { experiments } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth } from "../middleware/auth.js";

const router = Router();

router.use(requireAuth);

const createExperimentSchema = z.object({
  title: z.string().min(1).max(225),
  hypothesis: z.string().optional(),
  materials: z.string().optional(),
  protocol: z.string().optional(),
  observations: z.string().optional(),
  results: z.string().optional(),
  status: z.enum(["planned", "in-progress", "completed"]).optional(),
});

const updateExperimentSchema = createExperimentSchema.partial();

// GET all experiments for the authenticated user
router.get("/", async (req: Request, res: Response) => {
  const userExperiments = await db
    .select()
    .from(experiments)
    .where(eq(experiments.userId, req.userId!));

  res.status(200).json(userExperiments);
});

// GET a single experiment by ID (only if owned by user)
router.get("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const [experiment] = await db
    .select()
    .from(experiments)
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)));

  if (!experiment) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(experiment);
});

// POST create a new experiment
router.post("/", async (req: Request, res: Response) => {
  const parsed = createExperimentSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const [experiment] = await db
    .insert(experiments)
    .values({ ...parsed.data, userId: req.userId! })
    .returning();

  res.status(201).json(experiment);
});

// PATCH update an experiment
router.patch("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const parsed = updateExperimentSchema.safeParse(req.body);
  if (!parsed.success) {
    res.status(400).json({ error: "Validation failed", details: parsed.error.flatten() });
    return;
  }

  const [updated] = await db
    .update(experiments)
    .set({ ...parsed.data, updatedAt: new Date() })
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)))
    .returning();

  if (!updated) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(updated);
});

// DELETE an experiment
router.delete("/:id", async (req: Request, res: Response) => {
  const id = parseInt(req.params.id);

  const [deleted] = await db
    .delete(experiments)
    .where(and(eq(experiments.id, id), eq(experiments.userId, req.userId!)))
    .returning();

  if (!deleted) {
    res.status(404).json({ error: "Experiment not found" });
    return;
  }

  res.status(200).json(deleted);
});

export default router;

Your API is now fully protected. Every experiment belongs to a user, and no one can access experiments without a valid token. Next up, you will tackle the Secret Mission and add refresh token rotation for long-lived sessions.

Secret mission

Add Refresh Token Rotation

Your 15-minute access tokens work, but what happens when they expire? Right now, users have to log in again. Production apps solve this with refresh token rotation. You will add a database-backed refresh token system with family-based reuse detection, the same pattern recommended by RFC 6749 Section 10.4.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The lab_notebook PostgreSQL database (containing users, experiments, and refresh_tokens tables).
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to continue with the next part of the series.

  • Your PostgreSQL database stays running locally with all your tables and test data intact.
  • Your project files remain on your Desktop, ready for the next session.
  • There are no ongoing costs since everything is local.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for when you return.

  • Stop your dev server if it is still running by pressing Ctrl+C in the terminal.
  • Stop the PostgreSQL service to free memory. On macOS run:
brew services stop postgresql

On Windows, open Services and stop the postgresql service.

  • When you come back, start PostgreSQL again and your database will be exactly where you left it.

Delete - I don't want to use this again

Remove all project resources and start fresh. You can always re-run migrations later to recreate the tables.

  • Drop the PostgreSQL database by running:
dropdb lab_notebook
  • Delete the project folder. On macOS or Linux run:
cd ~/Desktop
rm -rf lab-notebook

On Windows run:

cd ~\Desktop
rmdir /s /q lab-notebook
  • If you want to remove the database user as well, connect to PostgreSQL and run DROP ROLE your_user; (replace with the username you created in Part 1).

Nice Work!

Nice Work!

Nice work! You just built a complete authentication layer for your bioengineering lab notebook API.

Your API has gone from wide-open anonymous access to a production-pattern identity system where every experiment belongs to a verified user.

You've learned how to:

  • Build a users table with hashed passwords using bcryptjs and Drizzle ORM, and implement register and login endpoints with Zod validation and JWT access token issuance.
  • Create a reusable requireAuth middleware that verifies tokens and pins the HS256 algorithm to prevent downgrade attacks.
  • Scope all experiment CRUD to the authenticated user via a userId foreign key so each user only sees their own data.
  • Secret Mission: Added refresh token rotation with family-based reuse detection, matching production OAuth 2.0 patterns.

Ready to quiz yourself?