URL Shortener Tutorial · Module 05 of 11

Authentication & Authorization

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.

~5–7 hrsargon2JWTRate Limit
← Back to Module 05 overview
Definition of Done

What You'll Have

  • POST /signup, POST /login, POST /logout endpoints.
  • Passwords hashed with argon2id; never logged.
  • JWT issued in an HttpOnly Secure SameSite=Lax cookie.
  • A requireAuth middleware; POST /shorten and DELETE /:code require it.
  • Ownership: only the link's owner (or an admin) can delete or update.
  • Per-IP token bucket on POST /shorten; 429 with Retry-After when exhausted.
  • Integration tests prove user A can't delete user B's link.
The Steps

Build It

STEP 1

Schema migration: users + owner_id

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
STEP 2

Install crypto and JWT dependencies

npm install argon2 jose cookie-parser
npm install -D @types/cookie-parser
STEP 3

Add a JWT module

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();
}
STEP 4

Add signup, login, logout

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));
STEP 5

Add a Redis token-bucket rate limiter

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();
  };
}
STEP 6

Wire it all into the app

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);
});
STEP 7

Try the full flow

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
✓ Verify: first 20 succeed, then a stream of 429s.
STEP 8

Add the negative authorization test

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.

STEP 9

Commit

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
Common Gotchas

If Something Goes Wrong

  • argon2 native build fails on Windows — use WSL2, or switch to bcrypt.
  • Cookie not sent — make sure cookie-parser is registered before routes; in tests use request.agent() to persist cookies.
  • JWT verifies in dev but fails in prod — different JWT_SECRET on each instance. Centralize in your secret manager (Module 09).
  • Rate limit too strict in tests — pass a higher capacity for NODE_ENV === 'test'.
What's Next

Move On