The problem: three different questions
Snip's security review lists three areas, often blurred together:
- Authentication: is this really Ana? (Logins, passwords, tokens.)
- Authorisation: may Ana edit link
x7Kp? (She owns it; her teammate can view it; an outsider can't.) - Abuse: is someone using Snip legitimately but for harm? (Phishing links, spam, bots inflating click counts.)
Store passwords so a leak is survivable
Assume the user table will leak one day. Passwords must be stored so that a leak doesn't reveal them:
- Hash, never encrypt. Encrypted passwords can be decrypted by whoever has the key; a hash can't be reversed.
- Use a slow, salted, memory-hard function. OWASP's recommendation is Argon2id (at least 19 MiB of memory, 2 iterations, parallelism 1), or scrypt if that's unavailable (N = 2¹⁷, r = 8, p = 1), with bcrypt for legacy systems. General-purpose hashes like SHA-256 are far too fast: attackers can try billions of guesses a second.
- A unique random salt per password, so identical passwords produce different hashes and precomputed tables are useless.
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(scryptCb) as (pw: string, salt: Buffer, len: number, opts: object) => Promise<Buffer>;
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; // OWASP's minimum for scrypt
export async function hashPassword(password: string) {
const salt = randomBytes(16);
const hash = await scrypt(password, salt, 32, PARAMS);
return `scrypt$${salt.toString("base64")}$${hash.toString("base64")}`;
}
export async function checkPassword(password: string, stored: string) {
const [, salt, hash] = stored.split("$");
const candidate = await scrypt(password, Buffer.from(salt, "base64"), 32, PARAMS);
return timingSafeEqual(candidate, Buffer.from(hash, "base64"));
}Tested: the right password verified, a version differing only in one capital letter didn't, the same password hashed twice gave two different strings (different salts), and each check took about a third of a second. That cost is the point: negligible for one login, ruinous for an attacker trying billions. Better still, let users sign in with a passkey or an identity provider, and don't store passwords at all.
Sessions or tokens
After login, each request must prove who it's from:
- Server-side sessions: a random session ID in a cookie, looked up in a session store. Revoking is deleting the row. Every request costs a lookup (usually a cache hit).
- Self-contained tokens (such as signed JWTs): the token carries the user ID and expiry, signed, so any server can verify it without a lookup. But like signed URLs (13.05 Signed URLs and access control), they're bearer tokens that can't be revoked before they expire without adding a lookup back in.
The common compromise: short-lived access tokens (minutes) plus a long-lived refresh token checked against the server
when renewing; revoking the refresh token cuts access off within minutes. Cookies carrying sessions should be
HttpOnly, Secure and SameSite.
Authorisation: from roles to relationships
- Ownership checks in code ("is
link.owner_idthe current user?") work until sharing arrives. - Roles (RBAC): admin, editor, viewer per team. Simple and auditable.
- Relationships (ReBAC): permissions follow relationships, "Ana can edit this link because she's an editor of the workspace that owns its folder". Google's Zanzibar handles Google's authorisation this way, as a central service storing relationships, at trillions of access control lists and millions of checks a second.
Whatever the model, check authorisation on the server, on every request, for the specific object: the commonest serious bug is an API that checks you're logged in but not that the link ID in the URL is yours.
Abuse: when the user is the threat
A link shortener is a favourite tool for phishing, because the short link hides the destination. Snip layers its defences:
- Check destinations against blocklists before and after creation (05.08 Bloom filters), and re-check periodically, since a clean page can turn malicious later.
- Friction proportional to trust: new accounts get low rate limits (14.04 Where to put a rate limiter); limits rise with account age, payment and good history.
- Interstitial warnings for suspicious destinations, and the ability to disable a link globally in seconds, including purging it from the CDN (06.04 Invalidation and staleness).
- Bot detection on clicks, so click counts reflect people.
- A reporting path for the public, handled quickly.
Seen in production
OWASP
DocsPassword Storage Cheat Sheet
Passwords should be hashed with modern, adaptive algorithms such as Argon2id, bcrypt or PBKDF2, rather than encrypted or stored in plain text, with a unique salt per password. The recommended minimum for Argon2id is 19 MiB of memory, 2 iterations and 1 degree of parallelism; for scrypt, a cost of 2^17, block size 8 and parallelism 1; bcrypt, with a work factor of at least 10, is for legacy systems.
What we foundHash with a slow, salted algorithm, Argon2id first, scrypt or bcrypt otherwise; never encrypt or store plain text.
Password Storage Cheat Sheet (OWASP)cheatsheetseries.owasp.org ↗Zanzibar
Zanzibar provides a uniform data model and configuration language for expressing access control policies across Google's services. It scales to trillions of access control lists and millions of authorisation requests per second, has maintained 95th-percentile latency under 10 milliseconds, and has had availability above 99.999% over three years of production use.
What we foundOne authorisation service for many products, at trillions of ACLs and under 10 ms at the 95th percentile.
Zanzibar: Google's Consistent, Global Authorization System (USENIX ATC 2019)usenix.org ↗Snip's API checks that a request has a valid session, then updates the link whose ID is in the URL. What's the vulnerability?
Recap
Interview questions
How would you store passwords and manage user sessions?