JWT and cookies
Register, log in, refresh and log out with JWT cookies, and restrict routes by role.
This page adds a full login flow to the tasks API: registration, login, a protected /me route, token refresh, logout and an admin-only middleware. The access token and the refresh token live in httpOnly cookies, so JavaScript on the page can never read them.
The examples use Mongoose for the users. With another library, replace the model calls and keep the rest.
Install
bun add jsonwebtoken bcryptjs mongoosebun add -d @types/jsonwebtokenSet JWT_SECRET and JWT_REFRESH_SECRET in .env, two different long random strings. The cookie helper comes from the `@riser-bob/cookie` package.
bun add @riser-bob/cookieThe token manager
One class signs and verifies both tokens. A verification failure returns null instead of throwing, which is exactly what AuthMiddleware expects. A missing secret throws a plain Error, so the client sees a generic 500 and the details stay in your logs.
import jwt from "jsonwebtoken"; export interface TokenPayload { _id: string; role: string;} export class TokenManager { private secret(name: "JWT_SECRET" | "JWT_REFRESH_SECRET"): string { const value = process.env[name]; if (!value) { throw new Error(name + " is not defined"); } return value; } generateAccessToken(payload: TokenPayload): string { return jwt.sign(payload, this.secret("JWT_SECRET"), { expiresIn: "15m" }); } generateRefreshToken(payload: TokenPayload): string { return jwt.sign(payload, this.secret("JWT_REFRESH_SECRET"), { expiresIn: "30d", }); } verifyAccessToken(token: string): TokenPayload | null { return this.verify(token, this.secret("JWT_SECRET")); } verifyRefreshToken(token: string): TokenPayload | null { return this.verify(token, this.secret("JWT_REFRESH_SECRET")); } private verify(token: string, secret: string): TokenPayload | null { try { const payload = jwt.verify(token, secret); if ( typeof payload === "string" || typeof payload["_id"] !== "string" || typeof payload["role"] !== "string" ) { return null; } return { _id: payload["_id"], role: payload["role"] }; } catch { return null; } }}The user schema
import mongoose, { Schema } from "mongoose";import { uuidPlugin } from "../../../utils/plugins/uuid.plugin"; export enum UserRole { ADMIN = "admin", USER = "user",} export interface IUser { name: string; email: string; password: string; role: UserRole; isActive: boolean; refreshToken: string | null;} export class UserSchema { public static readonly schema = new Schema<IUser>( { name: { type: String, required: true, minLength: 3, maxLength: 50 }, email: { type: String, required: true, unique: true, trim: true, lowercase: true, }, password: { type: String, required: true, minLength: 8 }, role: { type: String, required: true, enum: Object.values(UserRole), default: UserRole.USER, }, isActive: { type: Boolean, required: true, default: true }, refreshToken: { type: String, default: null }, }, { timestamps: true }, ); static { this.schema.plugin(uuidPlugin); } public static readonly model = mongoose.model<IUser>( "User", UserSchema.schema, );}The uuidPlugin is the one from the Mongoose page.
The rules
import type { ValidationSchema } from "riser-bob"; export const RegisterRule = { name: ["required", "string", "minLength:3", "maxLength:50"], email: ["required", "email", "maxLength:255"], password: ["required", "string", "minLength:8", "maxLength:40"],} satisfies ValidationSchema;import type { ValidationSchema } from "riser-bob"; export const LoginRule = { email: ["required", "email"], password: ["required", "string"],} satisfies ValidationSchema;The controller
A few choices in this controller are deliberate:
- Cookies are built with
CookieManagerand must be applied to the response withapplyTo, or the browser never receives them. secureis on in production and off onhttp://localhost, where browsers may refuseSecurecookies.- Login answers the same message for an unknown email and a wrong password, so an attacker cannot learn which emails exist.
- The refresh token is stored as a SHA-256 hash. A password hash such as bcrypt only reads the first 72 bytes, and every JWT of the same user starts with nearly the same 72 bytes, so it cannot tell two refresh tokens apart.
import { createHash } from "node:crypto";import { CookieManager } from "@riser-bob/cookie";import bcrypt from "bcryptjs";import type { HydratedDocument, Model } from "mongoose";import { Validator } from "riser-bob";import type { SpongHandler, SpongRequest } from "riser-bob";import type { TokenManager } from "../../config/token/token";import { UserRole, type IUser } from "../users/schemas/user.schema";import { LoginRule } from "./rules/login.rule";import { RegisterRule } from "./rules/register.rule"; const ACCESS_MAX_AGE = 15 * 60;const REFRESH_MAX_AGE = 30 * 24 * 60 * 60; const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); export class AuthController { constructor( private readonly tokens: TokenManager, private readonly users: Model<IUser>, ) {} private cookiesFor(spongReq: SpongRequest) { return CookieManager.fromRequest(spongReq.request, { defaults: { secure: process.env.NODE_ENV === "production", sameSite: "lax", }, }); } private async startSession( user: HydratedDocument<IUser>, cookies: CookieManager, ) { const payload = { _id: String(user._id), role: user.role }; const accessToken = this.tokens.generateAccessToken(payload); const refreshToken = this.tokens.generateRefreshToken(payload); user.refreshToken = sha256(refreshToken); await user.save(); cookies.set("token", accessToken, { maxAge: ACCESS_MAX_AGE }); cookies.set("refreshToken", refreshToken, { maxAge: REFRESH_MAX_AGE }); } readonly register: SpongHandler = async (spongReq, spongRes) => { const body = await spongReq.json<Record<string, unknown>>(); const result = Validator.validate<{ name: string; email: string; password: string; }>(body, RegisterRule); if (!result.valid) { return spongRes.SpongUnprocessableContent({ success: false, message: "Validation failed", errors: result.errors, }); } const { name, email, password } = result.data; if (await this.users.findOne({ email })) { return spongRes.SpongConflict({ success: false, message: "Email already registered", }); } const user = await this.users.create({ name, email, password: await bcrypt.hash(password, 12), role: UserRole.USER, isActive: true, }); const cookies = this.cookiesFor(spongReq); await this.startSession(user, cookies); return cookies.applyTo( spongRes.SpongCreated({ success: true, message: "Account created successfully", data: { id: user._id, name: user.name, email: user.email }, }), ); }; readonly login: SpongHandler = async (spongReq, spongRes) => { const body = await spongReq.json<Record<string, unknown>>(); const result = Validator.validate<{ email: string; password: string }>( body, LoginRule, ); if (!result.valid) { return spongRes.SpongUnprocessableContent({ success: false, message: "Validation failed", errors: result.errors, }); } const user = await this.users.findOne({ email: result.data.email }); const passwordMatches = user ? await bcrypt.compare(result.data.password, user.password) : false; if (!user || !passwordMatches) { return spongRes.SpongBadRequest({ success: false, message: "Invalid email or password", }); } if (!user.isActive) { return spongRes.SpongForbidden({ success: false, message: "This account is blocked", }); } const cookies = this.cookiesFor(spongReq); await this.startSession(user, cookies); return cookies.applyTo( spongRes.SpongOK({ success: true, message: "Logged in successfully", data: { id: user._id, name: user.name, email: user.email }, }), ); }; readonly me: SpongHandler = async (spongReq, spongRes) => { const authUser = spongReq.user; if (!authUser) { return spongRes.SpongUnauthorized({ success: false, message: "You are not authenticated", }); } const user = await this.users.findById(authUser.id).select("-password -refreshToken").lean(); if (!user) { return spongRes.SpongNotFound({ success: false, message: "User not found", }); } return spongRes.SpongOK({ success: true, data: user }); }; readonly logout: SpongHandler = async (spongReq, spongRes) => { const cookies = this.cookiesFor(spongReq); // Pass the same path, sameSite and secure you used to set the cookies. cookies.delete("token", { path: "/" }); cookies.delete("refreshToken", { path: "/" }); // Without applyTo the browser keeps the cookies. return cookies.applyTo( spongRes.SpongOK({ success: true, message: "Logged out successfully" }), ); }; readonly refreshToken: SpongHandler = async (spongReq, spongRes) => { const cookies = this.cookiesFor(spongReq); const refreshToken = cookies.get("refreshToken"); if (!refreshToken) { return spongRes.SpongUnauthorized({ success: false, message: "Refresh token is missing", }); } const payload = this.tokens.verifyRefreshToken(refreshToken); const user = payload ? await this.users.findById(payload._id) : null; if ( !user || !user.isActive || !user.refreshToken || user.refreshToken !== sha256(refreshToken) ) { return spongRes.SpongUnauthorized({ success: false, message: "Invalid or expired refresh token", }); } const accessToken = this.tokens.generateAccessToken({ _id: String(user._id), role: user.role, }); cookies.set("token", accessToken, { maxAge: ACCESS_MAX_AGE }); return cookies.applyTo( spongRes.SpongOK({ success: true, message: "Access token refreshed successfully", }), ); };}The router
import { AuthMiddleware, RiserBob } from "riser-bob";import type { AuthController } from "./auth.controller"; export class AuthRouter { constructor( private readonly bob: RiserBob, private readonly controller: AuthController, private readonly authMiddleware: AuthMiddleware, ) {} register(): void { // Public routes this.bob.spongPost("/api/auth/register", this.controller.register); this.bob.spongPost("/api/auth/login", this.controller.login); this.bob.spongPost("/api/auth/refreshToken", this.controller.refreshToken); // Protected routes this.bob.spongGet( "/api/auth/me", this.controller.me, this.authMiddleware.middleware, ); this.bob.spongPost( "/api/auth/logout", this.controller.logout, this.authMiddleware.middleware, ); }}The module
The module is where AuthMiddleware gets its verifier and its mode. The verifier returns an object with an id, plus the role that the admin middleware needs.
import { AuthMiddleware } from "riser-bob";import { RiserBob } from "riser-bob";import { TokenManager } from "../../config/token/token";import { UserSchema } from "../users/schemas/user.schema";import { AuthController } from "./auth.controller";import { AuthRouter } from "./auth.route"; export class AuthModule { register(bob: RiserBob): void { const tokens = new TokenManager(); const controller = new AuthController(tokens, UserSchema.model); const authMiddleware = new AuthMiddleware( (token) => { const payload = tokens.verifyAccessToken(token); if (!payload) return null; return { id: payload._id, role: payload.role }; }, { mode: "cookie", cookieName: "token" }, ); new AuthRouter(bob, controller, authMiddleware).register(); }}Protect the tasks by owner
With the user on spongReq, a task can belong to its author. Store the userId when you create it and filter by it everywhere else, so one user can never read or change another user's tasks.
readonly create: SpongHandler = async (spongReq, spongRes) => { const user = spongReq.user; if (!user) { return spongRes.SpongUnauthorized({ success: false, message: "You are not authenticated", }); } // ... validate the body, then: const task = await this.taskModel.create({ userId: user.id, title: result.data.title, }); return spongRes.SpongCreated({ success: true, data: task });}; // Reads and writes always filter by the owner:const task = await this.taskModel.findOne({ _id: id, userId: user.id });Roles with a second middleware
Authentication answers who the caller is. A second middleware answers what that caller may do. The AdminMiddleware from the Middleware page reads spongReq.user.role. Pass it after the auth middleware:
export class UsersRouter { constructor( private readonly bob: RiserBob, private readonly controller: UsersController, private readonly authMiddleware: AuthMiddleware, private readonly adminMiddleware: AdminMiddleware, ) {} register(): void { this.bob.spongGet("/api/users", this.controller.findAll); this.bob.spongPut( "/api/users/:id", this.controller.update, this.authMiddleware.middleware, this.adminMiddleware.middleware, ); this.bob.spongDelete( "/api/users/:id", this.controller.delete, this.authMiddleware.middleware, this.adminMiddleware.middleware, ); }}Register it and try it
new AuthModule().register(bob);new UsersModule().register(bob);new TasksModule().register(bob);# the -c and -b flags save and send the cookiescurl -c cookies.txt -X POST http://localhost:4000/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name":"Zen","email":"zen@example.com","password":"supersecret"}' curl -b cookies.txt http://localhost:4000/api/auth/me curl -b cookies.txt -c cookies.txt -X POST http://localhost:4000/api/auth/logoutFor a browser app on another origin, remember both halves: the API uses corsMiddleware with credentials: true and an explicit origin, and the front end uses fetch(url, { credentials: 'include' }).