Module A-13·32 min read

AuthN vs authZ, sessions vs JWT and token rotation, mTLS, secrets handling, and enforcing tenant isolation against the confused-deputy risk in pooled models.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module A-13 — Security by Design

What this module covers: Why authentication is largely a solved problem you should buy, and authorisation is bespoke to your domain and therefore where the breaches are. Sessions versus tokens done properly, including the trade that decides it — you cannot revoke a JWT — and the short-access-plus-revocable-refresh design that gets both properties. Authorisation models, and the structural rule this course has deferred to here four times: enforce at a chokepoint, because a filter each query must remember is a filter that will be forgotten — with Postgres row-level security as the strongest available mechanism. Broken object-level authorisation, and why an unguessable ID is defence in depth rather than authorisation. The confused deputy in its four common forms, including the internal service that trusts a header and the LLM that acts as root. Service-to-service identity, secrets that are short-lived rather than merely hidden, envelope encryption that makes crypto-shredding possible, and an audit log that covers reads. Plus the automated test that is worth more than any of it: proving every endpoint rejects cross-tenant access.


Authentication Is Bought; Authorisation Is Where the Breaches Are

Two questions that get conflated:

  • Authentication (authn): who is this? Solved, standardised, and available as a product. OIDC providers handle passwords, MFA, passkeys, breach detection, session management and the dozen flows you would get subtly wrong.
  • Authorisation (authz): may this subject do this action on this object? Entirely specific to your domain, impossible to buy wholesale, and consequently the source of most serious application vulnerabilities. Broken object-level authorisation has been at or near the top of the OWASP list for years, and it is not an exotic bug — it is a missing WHERE clause.

That asymmetry should shape where effort goes. Buy authentication. Design authorisation as a structural property of the system, not as a check developers remember to write.

Analogy: a hotel. Authentication is the front desk confirming you are the person on the booking — a standard process, done the same way everywhere, and you would not invent your own. Authorisation is the door locks: which rooms, which floors, the gym, the safe. The failure that loses a hotel its reputation is never "we let in someone with a fake ID" — it is a key card that opens every room on the floor because the encoding machine was set up wrong, or a housekeeper's master key used to enter a guest's room because nobody checked whether that guest had requested service. The second one is the confused deputy, and it is this module's recurring theme.


Authentication: the Short Version, Since You Should Not Build It

Use an OIDC provider. If you nonetheless own the login flow, the non-negotiables:

  • Password hashing with argon2id (or bcrypt/scrypt) with tuned parameters. Never a general-purpose hash — SHA-256 is fast, which is exactly the property you do not want. Store the parameters with the hash so you can strengthen them later.
  • Length over composition. Minimum length, a check against known-breached password lists, and no rules about symbols — composition rules push users towards Password1! and towards reuse.
  • Rate limit per account and per IP and globally (Module P-18), because each dimension alone is bypassable: per-account stops brute force on one user, per-IP stops one host trying many users, global stops a slow distributed attempt.
  • MFA, with passkeys/WebAuthn as the strongest practical option (phishing-resistant because the credential is bound to the origin), TOTP as the fallback, SMS as a last resort given SIM-swap risk. No "security questions" — they are low-entropy public facts.
  • Reset flows: single-use, short-lived, invalidated on use and on password change, and the response must not reveal whether an account exists.

OAuth2/OIDC pitfalls worth knowing even when using a provider: use the authorisation-code flow with PKCE (never implicit); validate state to prevent CSRF on the callback; verify the ID token's signature, issuer, audience and expiry rather than decoding it; and match redirect_uri exactly — a permissive redirect pattern is an open redirect, and an open redirect in an OAuth flow is token theft.


Sessions or Tokens: the Trade Is Revocation

This is the most confused area in practice, so state the trade plainly.

Server-side sessionJWT (self-contained)
What the client holdsan opaque random IDsigned claims
Verificationa lookup (Redis GET, ~0.3 ms)signature check, no I/O
Revocationimmediateimpossible until expiry
Changing permissions mid-sessionimmediatenot until the token refreshes
Cross-service verificationneeds the storeworks anywhere with the public key
Sizetinyhundreds of bytes to a few KB, on every request

The recurring failure: a team chooses JWTs for "scalability", sets a 24-hour expiry, and discovers that logout does not log out — a stolen token works for a day, a fired employee retains access, a demoted user keeps their old permissions, and a compromised account cannot be locked out. The "stateless" property they bought was the ability to skip a sub-millisecond Redis lookup; the property they sold was revocation.

The design that gets both:

text

Revocation is then bounded by the access token's lifetime, which you choose. Add refresh-token rotation with reuse detection: each refresh issues a new refresh token and invalidates the old one, and if an old one is ever presented again, that indicates theft — so revoke the entire token family and force re-authentication. This turns a stolen refresh token from persistent access into a detectable event.

Two more implementation rules:

Store tokens in an HttpOnly; Secure; SameSite=Lax cookie, not localStorage. Anything in localStorage is readable by any XSS on your origin; an HttpOnly cookie is not. Cookies then need CSRF defence — SameSite handles most of it, with a token pattern for cross-site API cases.

Validate the algorithm explicitly. alg: none and HS256/RS256 confusion (where the attacker signs with the public key as an HMAC secret) are old bugs that still appear. Pin the expected algorithm and key rather than trusting the token's header, and never put PII in a JWT — it is signed, not encrypted, and any holder can read it.


Authorisation: Enforce at a Chokepoint

Three models, and they compose:

  • RBAC — permissions attach to roles, roles attach to users. Simple, coarse, and sufficient for administrative capabilities ("who may issue refunds").
  • ABAC — decisions from attributes and a policy ("a manager may approve expenses under £5,000 in their own department during business hours"). Flexible; needs a policy engine and careful testing.
  • ReBAC — decisions from relationships in a graph ("Alice may edit this document because she is an editor of the folder containing it"), the Google Zanzibar model implemented by OpenFGA, SpiceDB and similar. This is the right model for document/collaboration products, where permissions are inherited through structure and the "why" of a decision matters.

But the model matters less than where the decision is applied, and this is the point four earlier modules deferred to here. Modules P-8, P-20, P-21 and A-12 each concluded that a tenant or ACL filter "must be applied by a layer no query can omit." Here is why that phrasing is deliberate:

A check that each handler must remember is a check that will eventually be forgotten — by a new joiner, in a rushed fix, in a new endpoint, in a background job, in an admin tool, in a data export.

There is no code-review discipline that survives a hundred endpoints and three years. So the enforcement must be structural. Three mechanisms, strongest last:

1. A scoped data-access layer. Make it impossible to construct an unscoped query: the repository takes a subject or tenant context in its constructor, and there is no method that omits it. In a typed language, make the unscoped variant require an explicit, greppable, reviewed escape hatch (unsafeUnscoped()), so its use is visible rather than accidental.

2. A policy decision point. A central service or embedded library that answers "may S do A on O?", with policy as versioned, testable data. It gives uniform decisions and an audit trail; the costs are a network hop (mitigate with local caching plus short TTLs) and an availability dependency that must fail closed (Module A-7) — an authorisation service being down means deny, which needs a cached-decision path so a blip is not a total outage.

3. Row-level security in the database, which is the strongest because it holds even when application code is wrong:

sql

A forgotten WHERE tenant_id now returns nothing rather than everything. Three caveats that matter operationally: use SET LOCAL inside a transaction (a session-level SET leaks across clients under a transaction-pooling pooler, exactly as Module A-2's advisory locks do); the table owner and any role with BYPASSRLS are exempt, so the application role must not be the owner; and policies are evaluated per row, so they need to be indexable predicates rather than expensive functions.

Broken object-level authorisation, and the ID myth

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.