System Design
AuthN vs AuthZ
Identity vs permission. Why mixing them up is the #1 source of security holes in young codebases.
Authentication (AuthN) vs Authorization (AuthZ)
Two words, one letter apart, and the gap between them is where most early data breaches live. Authentication answers "who are you?" — authorization answers "are you allowed to do this?" A system can get the first one perfectly right and still let any logged-in stranger read every other customer's records, because it never asked the second question.
| AuthN | AuthZ | |
|---|---|---|
| Question | "Who are you?" | "What can you do?" |
| When | Once per session | Every single request |
| Output | Identity (user id, JWT) | Allow / Deny |
| Source of truth | Identity provider (IdP) | Policy engine / app code |
| Failure code | 401 Unauthorized | 403 Forbidden |
| Tech | OAuth, OIDC, SAML, password + TOTP | RBAC, ABAC, ReBAC, OPA, app code |
The single most important row is "When." You authenticate once and carry a credential. You must authorize every time you touch data — because the same authenticated user is allowed to read their own invoice and forbidden from reading yours, and only the per-request check knows the difference.
AuthN — proving identity
The user claims "I am alice"; authentication makes them prove it — password + MFA, a passkey, a hardware token, or social login delegated to Google/GitHub as the IdP. The output is a credential, usually a signed JWT or an opaque session cookie, that your services trust for the life of the session.
The standard stack: OAuth 2.0 + OIDC issue the token, the JWT is self-contained and signed by the IdP, with a short access-token TTL (~15 min) plus a longer-lived refresh token. The usual providers: Better Auth, Auth0, Okta, Cognito, Keycloak. Get this right and you know, reliably, who is calling. You still know nothing about what they're allowed to do.
AuthZ — checking permissions
Alice is authenticated. She wants to PATCH /documents/42. Allowed?
Three models, in rough order of power and cost:
RBAC — role-based
Users hold roles; roles grant permissions on resource types:
alice ∈ editors, and editors may EDIT documents. Coarse, easy to
reason about, easy to audit. It falls over the moment you need "alice may
edit these documents but not those" — roles don't see individual
resources.
ABAC — attribute-based
Decisions consider attributes of the user, resource, and
environment: alice may EDIT doc IF doc.org == alice.org AND now IN business_hours. Expressive enough for multi-tenant SaaS, but the rule
combinations get hard to audit ("which rules could possibly grant this?").
Pairs with policy engines like OPA or Cedar.
ReBAC — relationship-based
Permissions follow a graph of relationships, the model Google's Zanzibar
made famous. "Alice may edit this doc if she owns the folder it's in, or
someone who owns it shared it with her, or she's in a group that was
granted access." You store tuples — doc:42#editor@alice,
folder:7#owner@alice, doc:42#parent@folder:7 — and a check walks the
graph. It's the right model for sharing-heavy products (Google Docs,
Notion); engines include SpiceDB and Permify. The power costs you a new
distributed system to operate and keep consistent.
| Model | Granularity | Audit | Complexity | When |
|---|---|---|---|---|
| RBAC | Coarse (per role) | Simple | Low | Internal/admin tools |
| ABAC | Fine (per rule) | Hard (rule combos) | Medium | Multi-tenant SaaS |
| ReBAC | Per-relationship | Per-tuple | High | Sharing/collab products |
| ACL | Per-resource | Easy | Low | File systems, S3 buckets |
A war story: the leak that incremented to 50,000
A SaaS startup shipped a clean-looking endpoint:
// Looks fine. Authenticates. Never authorizes.
app.get('/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.find(req.params.id);
res.json(invoice); // <- whose invoice? nobody checked.
});
requireAuth did its job: valid JWT, real user. But nothing checked that
the invoice belonged to that user. Invoice IDs were sequential
integers. A curious customer changed /invoices/1041 to /invoices/1042
in the URL bar, saw someone else's invoice, wrote a five-line script, and
walked the IDs from 1 to ~50,000 — every customer's billing data, names,
amounts, addresses. This class of bug has a name: IDOR (insecure
direct object reference), and it is the most common serious flaw in young
products.
The fix is one clause, at the data boundary:
const invoice = await db.invoices.find(req.params.id);
if (!invoice || invoice.ownerId !== req.user.id) {
return res.status(404).end(); // 404, not 403: don't even confirm it exists
}
res.json(invoice);
The check has to live in the service that owns the resource and run on every request that touches data — not only at the API gateway. A gateway can do a coarse "is this user allowed near this route"; only the data-owning service knows that this row belongs to this user. Returning 404 instead of 403 for someone else's object is a nice touch: it doesn't even confirm the ID exists.
Token gotchas
| Token type | Pros | Cons |
|---|---|---|
| Session cookie (opaque) | Revocable instantly; stateful | Lookup on every request |
| JWT (self-contained) | Stateless; scales horizontally | Can't revoke before expiry; grows with claims |
| Short JWT + server session | Best of both | More moving parts |
The JWT-vs-session tension is really about revocation. A self-contained JWT is valid until it expires — if it's stolen and the TTL is 15 minutes, the attacker has 15 minutes, and there's nothing you can do mid-flight without a denylist. Most production systems split the difference: a short access-token JWT for scale, a refresh token to renew it, and a server-side session you can kill to force re-authentication.
Multi-tenant authorization
In SaaS, almost every check reduces to "is this user in this
tenant allowed to do this action on this resource?" The naive
guard — WHERE org_id = current_user.org_id on every query — is correct
but fragile: forget it on one endpoint and you've leaked across tenants
(the IDOR story, scaled to whole organizations). Stronger isolation pushes
the check below the app: Postgres row-level security enforces the
tenant predicate in the database itself, and a schema- or database-per-tenant
layout makes a cross-tenant read structurally impossible rather than
merely discouraged.
[CONCEPT]defense-in-depth is why authorization belongs at several layers, not one. [CONCEPT]rate-limiting is what blunts AuthN brute-force before it ever reaches the password check.