By the end of this tutorial, you'll have user accounts with argon2 password hashing, JWT-cookie sessions, ownership checks on every link, and a Redis-backed rate limiter on the create endpoint.
← Back to Module 05 overviewPOST /signup, POST /login, POST /logout endpoints.HttpOnly Secure SameSite=Lax cookie.requireAuth middleware; POST /shorten and DELETE /:code require it.POST /shorten; 429 with Retry-After when exhausted.npm run migrate -- create users-and-ownership
In the new migration:
export const up = (pgm) => {
pgm.createTable('users', {
id: { type: 'bigserial', primaryKey: true },
email: { type: 'text', notNull: true, unique: true },
password_hash: { type: 'text', notNull: true },
role: { type: 'text', notNull: true, default: 'user' },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }
});
pgm.addColumns('links', {
owner_id: { type: 'bigint', references: 'users(id)', onDelete: 'SET NULL' }
});
pgm.createIndex('links', 'owner_id');
};
export const down = (pgm) => {
pgm.dropColumns('links', ['owner_id']);
pgm.dropTable('users');
};
npm run migrate:up
npm install argon2 jose cookie-parser npm install -D @types/cookie-parser
Add JWT_SECRET to .env (a long random string — openssl rand -base64 32).
Create src/auth.ts:
import { SignJWT, jwtVerify } from 'jose';
import type { Request, Response, NextFunction } from 'express';
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET ?? 'dev-only-secret');
export type Session = { sub: string; role: 'user' | 'admin' };
export async function issueToken(s: Session) {
return await new SignJWT(s as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('15m')
.sign(SECRET);
}
export async function readSession(token: string | undefined): Promise<Session | null> {
if (!token) return null;
try {
const { payload } = await jwtVerify(token, SECRET);
return { sub: String(payload.sub), role: (payload.role ?? 'user') as 'user' | 'admin' };
} catch { return null; }
}
declare module 'express-serve-static-core' {
interface Request { user?: Session }
}
export async function loadUser(req: Request, _res: Response, next: NextFunction) {
req.user = (await readSession(req.cookies?.token)) ?? undefined;
next();
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.user) return res.sendStatus(401);
next();
}
Create src/routes/auth.ts:
import { Router } from 'express';
import argon2 from 'argon2';
import { pool } from '../db.js';
import { issueToken } from '../auth.js';
export const authRouter = Router();
const cookieOpts = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: 15 * 60 * 1000
};
authRouter.post('/signup', async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string' || password.length < 10)
return res.status(400).json({ error: 'bad input' });
const hash = await argon2.hash(password, { type: argon2.argon2id });
try {
const { rows } = await pool.query(
'INSERT INTO users(email, password_hash) VALUES (LOWER($1), $2) RETURNING id, role',
[email, hash]
);
const token = await issueToken({ sub: String(rows[0].id), role: rows[0].role });
res.cookie('token', token, cookieOpts).status(201).json({ id: rows[0].id });
} catch (e: any) {
if (e.code === '23505') return res.status(409).json({ error: 'email taken' });
throw e;
}
});
authRouter.post('/login', async (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== 'string' || typeof password !== 'string')
return res.status(400).json({ error: 'bad input' });
const { rows } = await pool.query(
'SELECT id, password_hash, role FROM users WHERE email = LOWER($1)',
[email]
);
const ok = rows.length === 1 && await argon2.verify(rows[0].password_hash, password);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
const token = await issueToken({ sub: String(rows[0].id), role: rows[0].role });
res.cookie('token', token, cookieOpts).json({ ok: true });
});
authRouter.post('/logout', (_req, res) => res.clearCookie('token').sendStatus(204));
Create src/ratelimit.ts:
import Redis from 'ioredis';
import type { Request, Response, NextFunction } from 'express';
const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');
const SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * refill)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', key, 3600)
return allowed`;
export function rateLimit(opts: { id: (r: Request) => string; capacity: number; refillPerSec: number }) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = `rl:${opts.id(req)}`;
const allowed = await redis.eval(
SCRIPT, 1, key, String(opts.capacity), String(opts.refillPerSec), String(Date.now() / 1000)
) as number;
if (allowed === 0) return res.set('Retry-After', '5').sendStatus(429);
next();
};
}
Update src/app.ts:
import cookieParser from 'cookie-parser';
import { authRouter } from './routes/auth.js';
import { loadUser, requireAuth } from './auth.js';
import { rateLimit } from './ratelimit.js';
app.use(express.json());
app.use(cookieParser());
app.use(loadUser);
app.use('/auth', authRouter);
const limit = rateLimit({
id: (r) => r.user?.sub ?? r.ip,
capacity: 20, refillPerSec: 0.2 // 20-burst, ~12/min
});
app.post('/shorten', requireAuth, limit, async (req, res) => {
// …existing logic…
// when inserting:
await pool.query(
'INSERT INTO links(code, target_url, owner_id) VALUES ($1, $2, $3)',
[code, url, Number(req.user!.sub)]
);
});
app.delete('/:code', requireAuth, async (req, res) => {
const { rows } = await pool.query('SELECT owner_id FROM links WHERE code = $1', [req.params.code]);
if (rows.length === 0) return res.sendStatus(404);
if (rows[0].owner_id !== Number(req.user!.sub) && req.user!.role !== 'admin')
return res.sendStatus(403);
await pool.query('DELETE FROM links WHERE code = $1', [req.params.code]);
// invalidate cache as in module 4
res.sendStatus(204);
});
npm run dev
# Sign up
curl -i -c jar.txt -X POST http://localhost:3000/auth/signup \
-H 'content-type: application/json' \
-d '{"email":"a@example.com","password":"correcthorsebattery"}'
# Create a link as that user
curl -s -b jar.txt -X POST http://localhost:3000/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'
# Without the cookie — should be 401
curl -i -X POST http://localhost:3000/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'
# Hit the rate limit
for i in $(seq 1 50); do curl -s -o /dev/null -w '%{http_code} ' -b jar.txt \
-X POST http://localhost:3000/shorten \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'; done
Append to tests/api.test.ts:
it('user A cannot delete user B's link', async () => {
const a = request.agent(app());
await a.post('/auth/signup').send({ email: `a+${Date.now()}@x.com`, password: 'x'.repeat(12) });
const create = await a.post('/shorten').send({ url: 'https://example.com' });
const code = create.body.code;
const b = request.agent(app());
await b.post('/auth/signup').send({ email: `b+${Date.now()}@x.com`, password: 'x'.repeat(12) });
const del = await b.delete(`/${code}`);
expect(del.status).toBe(403);
});
npm run test:integration — must pass.
git checkout -b module-05 git add . git commit -m "module 05: argon2 auth, JWT cookies, ownership, rate limit" git push -u origin module-05
bcrypt.cookie-parser is registered before routes; in tests use request.agent() to persist cookies.JWT_SECRET on each instance. Centralize in your secret manager (Module 09).NODE_ENV === 'test'.