AuthN vs authZ, sessions vs JWT and token rotation, mTLS, secrets handling, and enforcing tenant isolation against the confused-deputy risk in pooled models.
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_uriexactly — 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 session
JWT (self-contained)
What the client holds
an opaque random ID
signed claims
Verification
a lookup (Redis GET, ~0.3 ms)
signature check, no I/O
Revocation
immediate
impossible until expiry
Changing permissions mid-session
immediate
not until the token refreshes
Cross-service verification
needs the store
works anywhere with the public key
Size
tiny
hundreds 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
The most common serious web vulnerability is this:
ts
And the belief to kill: "the IDs are UUIDs, so nobody can guess them" is not authorisation. IDs leak — in URLs shared in tickets, in emails, in referrer headers, in exports, in logs, in a partner's integration, and from any other endpoint that returns them legitimately. Unguessable identifiers are worthwhile defence in depth (Module P-3 covers the enumeration and volume-disclosure argument), and they are not an access-control mechanism.
The same rule applies to writes, to lists (an unfiltered list is the same bug in bulk), to background jobs (a job carrying an object ID must carry and re-check the subject too), and to exports and admin tools, which are where it is most often missed.
The Confused Deputy, Which Is the Same Bug Four Times
A confused deputy is a component with more privilege than its caller, performing an action on the caller's behalf without checking the caller's rights. It is the shape underlying a surprising share of real breaches, and it appears in four disguises:
1. The internal service that trusts a header. Service A authenticates the user and calls B with X-User-Id: 42. B trusts it. Now anyone who can reach B — an SSRF, a misconfigured ingress, a compromised sidecar, a developer with curl on a bastion — can act as any user. The defence is that identity must be cryptographically verifiable at every hop: a signed token with an audience restriction that B validates, not a header B believes.
2. The privileged batch job. A report generator or export worker runs as a superuser "because it needs to read everything," and takes a tenant ID as a parameter. Pass the wrong parameter — a bug, a replayed job, a crafted request — and it reads any tenant's data. The defence is to run it with the requester's authority, or to have it derive the tenant from an authenticated source and pass through the same RLS-enforced path as a normal request.
3. The LLM with tools (Module A-12). The model is influenced by untrusted retrieved content and then calls a tool with service credentials. Same shape: authorise the tool call as the user, at the point of resource access.
4. SSRF (Module P-17). Your server's network position is a privilege; a user-supplied URL borrows it to reach an internal service that trusts anything from inside the perimeter.
The unifying rule, worth memorising:
Authenticate the caller at the boundary; authorise at the point of resource access, with the caller's identity, not the service's.
Re-authorising at the boundary and then acting as root is the mistake in every one of the four.
Multi-tenant isolation, concretely
This is Module P-8's deferred security half:
The tenant ID comes from the authenticated session, never from a request parameter, body field, or header the client controls. A ?tenantId= that is honoured is a one-line cross-tenant breach.
Enforce with RLS or a scoped repository, per the chokepoint argument.
Per-tenant encryption keys for defence in depth, which also enable per-tenant crypto-shredding (Module O-9).
And write the test. An automated harness that enumerates every route from your router and asserts that tenant A's credentials receive 404/403 on tenant B's objects is one of the highest-value security tests available — because it converts "we remembered on every endpoint" from a hope into a build failure. New endpoints are then covered by construction rather than by review.
Service-to-Service Identity, Zero Trust, and Blast Radius
A shared static API key across services is not identity. It cannot be attributed, cannot be revoked for one caller, and is copied into every config and log. Use either mTLS with per-workload identities (which a mesh gives you with automatic rotation — Module A-5) or short-lived signed tokens with an audience claim, so a token for service B is useless at service C.
Zero trust means network position confers no privilege. The flat internal network — where anything inside can reach anything else and internal services skip authentication — is the single largest blast-radius multiplier in most architectures: one compromised container becomes access to every database. Authenticate and authorise internal calls, segment the network, and give each service its own credentials to each dependency.
Least privilege in the database is under-used and cheap:
The application role should not own its tables and should have no DDL rights — so a SQL injection cannot DROP or ALTER, and RLS applies to it.
Migrations run as a separate role, in a separate credential, from a separate process (Module O-4).
Read-only workloads get a read-only role (Module P-22).
No superuser anywhere near application code.
Assume one component will be compromised, and ask what it reaches. That question, asked honestly, usually reorganises more than any individual control.
Secrets: Short-Lived Beats Hidden
The progression, worst to best:
In the repository. (Assume already leaked. Deletion does not help — git history retains it, so the only remediation is rotation.)
In an environment variable set from a CI secret. Better; still long-lived and visible to anything in the process, including a dependency that decides to print process.env.
In a secrets manager, fetched at start-up. Better; still long-lived.
Dynamic, short-lived credentials issued per workload with a TTL — database credentials valid for an hour, issued to a specific instance, automatically rotated. A leak then has a bounded lifetime.
Two practical requirements. Rotation must be non-breaking, which means overlapping validity: two keys active at once, sign or accept with both, retire the old one after a window — exactly Module P-17's webhook secret rotation, generalised. And secret scanning belongs in CI and in a pre-commit hook, because the cheapest place to catch a committed key is before it is pushed.
Encryption, in three layers:
In transit: TLS everywhere, including internal traffic. "It is inside the VPC" is the flat-network assumption again.
At rest: mostly the platform's job, and the part that matters is key management — who can decrypt, and is that audited. Full-disk encryption protects against a stolen drive and not against a compromised application.
At the field level, for the most sensitive data, using envelope encryption: a per-tenant or per-subject data key (DEK) encrypts the field, and a key-management key (KEK) encrypts the DEK. This buys two things: a compromise of one key exposes one subject, and deleting a DEK renders that subject's data unreadable everywhere at once — which is the crypto-shredding mechanism Module O-9 depends on for erasure across immutable logs and backups.
Boundaries, Audit, and What to Watch
Validate at the boundary, encode at the point of use. These are different operations and conflating them causes both bugs and false confidence:
Parameterised queries always — no string-built SQL, including in "internal" tools and reports. An ORM is not immunity; its raw-query escape hatch is where injections live.
Output encoding is context-specific: HTML body, attribute, JavaScript, URL and CSS contexts each need different escaping, which is why a framework's auto-escaping plus a strict CSP is worth far more than manual sanitisation.
Schema-validate untrusted input with an allowlist (a zod/joi schema at every entry point), rejecting unknown fields rather than passing them through to a model that may bind them.
SSRF egress controls (Module P-17), file-upload handling and a separate content origin (Module A-11), and no deserialisation of untrusted data into arbitrary types.
An audit log is a distinct artefact from application logs. Append-only, immutable, retained per policy, and recording who did what to which object, when, and on whose behalf (that last field is what makes a confused-deputy incident reconstructable). Two points usually missed: it must cover reads of sensitive data, because "who looked at this customer's record" is the question a regulator asks; and it must be tamper-evident — write-once storage or a hash chain — since an audit log an attacker can edit proves nothing. This is Module P-24's immutable-ledger argument applied to security events.
What to watch, because security signals are rarely in an error budget:
Authentication failure rate per account and globally — a spike is credential stuffing.
Authorisation denial spikes, which are ambiguous in a useful way: either an attack or a broken deploy, and both need someone to look.
Rate-limiter rejections by principal (Module P-18), impossible-travel and new-device events, and anomalous data-export volumes — the last being the best single indicator of exfiltration.
And the honest note: most breaches are discovered by third parties, so the value of this instrumentation is measured in time to detection, not in prevention.
Why this matters in production: the security failures that actually happen are mundane. A missing tenant check on one export endpoint added during a deadline. A 24-hour JWT that made a termination take a day to take effect. An internal service trusting X-User-Id. A batch job running as superuser with a tenant ID from a query parameter. A database credential in git history, rotated only after the leak was found. None of these required a sophisticated attacker, and all of them are prevented by structure — a chokepoint, a short token lifetime, a verifiable identity, least privilege — rather than by vigilance.
Where This Shows Up in Your Stack
PostgreSQL: row-level security with SET LOCAL app.tenant_id inside the transaction is the strongest isolation mechanism available to most applications, and it survives application bugs. Watch three things: SET LOCAL rather than session-level SET under a transaction-pooling pooler (Module A-2's leak, identically); the application role must not own the tables and must lack BYPASSRLS; and policy predicates must be indexable. Separate roles for app, migrations and reporting, no superuser in application config, and pgcrypto or application-side envelope encryption for the highest-sensitivity fields.
Node.js:argon2 for passwords; jose for JWTs with the algorithm and key pinned explicitly; cookie flags HttpOnly; Secure; SameSite; helmet plus a real CSP; zod schemas at every boundary with unknown fields rejected; no eval and no dynamic require of user-influenced paths. Remember that any dependency runs with your process's full privilege, so a secrets manager with short-lived credentials limits what a malicious package can take.
Redis: as a session store it becomes security-critical infrastructure — bind it to a private network, require authentication, enable TLS, and note that evicting session keys is a mass logout, so sessions belong on an instance whose eviction policy cannot touch them (Module P-12).
Service mesh: mTLS with per-workload identities and automatic rotation is the mesh's strongest justification (Module A-5), and its service-to-service authorisation policies give you a network-level chokepoint that complements the application-level one.
Secrets managers: prefer dynamic short-lived database credentials over long-lived stored ones; implement rotation with overlapping validity so it is never a flag day (Module P-17); and run secret scanning in CI and pre-commit, remembering that a committed secret must be rotated rather than deleted.
LLM tooling: every tool call authorised as the user, least privilege and read-only defaults, and model output treated as untrusted input at each point of use (Module A-12).
Summary
Buy authentication; design authorisation. Authn is standardised and available as a product; authz is domain-specific and is where the serious vulnerabilities live — broken object-level authorisation is a missing WHERE clause, not an exotic attack.
If you own login:argon2id with tuned parameters (never a fast hash), length and breach-list checks instead of composition rules, rate limits per account and per IP and globally, passkeys/WebAuthn as the strongest MFA, and reset flows that are single-use, short-lived and do not reveal account existence. In OAuth: code flow with PKCE, validated state, verified ID-token signature/issuer/audience/expiry, and exactredirect_uri matching, since an open redirect there is token theft.
The session-versus-JWT trade is revocation, and nothing else matters as much. A 24-hour JWT means logout does not log out, a termination takes a day, and a demoted user keeps old permissions — in exchange for skipping a sub-millisecond lookup. Use short-lived access tokens plus a revocable server-side refresh token, with rotation and reuse detection so a stolen refresh token becomes a detectable event rather than persistent access.
Tokens live in HttpOnly; Secure; SameSite cookies, not localStorage (which any XSS can read), with CSRF defence; pin the expected JWT algorithm rather than trusting the header; and never put PII in a JWT, which is signed and not encrypted.
RBAC for capabilities, ABAC for conditional policy, ReBAC for permissions inherited through structure — but where the decision is enforced matters more than the model.
Enforce at a chokepoint, because a check each handler must remember will be forgotten — by a new joiner, a rushed fix, a background job, an export, an admin tool. Use a data-access layer where an unscoped query cannot be constructed without a greppable escape hatch, a policy decision point that fails closed with a cached-decision path, and above all row-level security, which holds even when application code is wrong. With RLS: SET LOCAL inside a transaction (session-level leaks through a pooler), the app role must not own the tables or hold BYPASSRLS, and predicates must be indexable.
"The IDs are UUIDs" is not authorisation. IDs leak through tickets, emails, referrers, exports, logs and legitimate endpoints; unguessable identifiers are defence in depth. Check the subject-object relationship in the query, return 404 rather than 403 to avoid confirming existence, and apply the same rule to writes, lists, background jobs, exports and admin tools.
The confused deputy is one bug in four disguises: an internal service trusting X-User-Id; a batch job running as superuser with a tenant ID from a parameter; an LLM calling tools with service credentials; and SSRF borrowing your network position. The rule: authenticate at the boundary, authorise at the point of resource access, with the caller's identity — never re-authorise at the boundary and then act as root.
For multi-tenancy: the tenant ID comes from the authenticated session and never from a client-controlled field; enforce with RLS or a scoped repository; use per-tenant keys for defence in depth and crypto-shredding; and write the harness that enumerates every route and asserts cross-tenant access is refused — it turns "we remembered everywhere" into a build failure and covers new endpoints by construction.
A shared static API key is not identity. Use mTLS with per-workload identities or short-lived tokens with an audience claim. Network position must confer no privilege — the flat internal network is the biggest blast-radius multiplier most systems have. And in the database, the app role owns nothing and has no DDL, migrations use a separate role, reporting is read-only, and nothing runs as superuser.
Secrets should be short-lived rather than merely hidden. Dynamic per-workload credentials with a TTL beat a secrets manager, which beats an environment variable, which beats the repository — where a committed secret must be rotated, because git history retains it. Rotation needs overlapping validity so it is never a flag day, and secret scanning belongs in CI and pre-commit.
Use envelope encryption for the most sensitive fields: a per-subject data key encrypted by a management key, which bounds the blast radius of one key and makes deleting the key render that subject's data unreadable everywhere at once — the crypto-shredding mechanism erasure depends on.
Validate at the boundary, encode at the point of use. Parameterised queries everywhere including internal tools, context-specific output encoding backed by a framework and a strict CSP, allowlist schemas that reject unknown fields, SSRF egress controls, and a separate origin for user content.
The audit log is a separate, append-only, tamper-evident artefact recording who did what to which object, when, and on whose behalf — and it must cover reads of sensitive data, because that is the question a regulator asks. An audit log an attacker can edit proves nothing.
Watch authn failure rates, authorisation denial spikes (an attack or a broken deploy — both need a human), limiter rejections by principal, and anomalous export volumes, which is the best single exfiltration signal. Most breaches are found by third parties, so this instrumentation buys time-to-detection rather than prevention.
The real failures are mundane: one export endpoint missing a tenant check, a 24-hour token, a trusted header, a superuser batch job, a credential in git history. All are prevented by structure rather than vigilance.
The next module is the Architect capstone: a global news feed. It needs almost everything in this phase at once — fan-out on write versus on read and the celebrity account that breaks the first, ranking as a system rather than a query, a cache hierarchy that earns its complexity, multi-region reads with the consistency trade stated, and the authorisation model that decides what appears in a feed at all.
Knowledge Check
A team adopts JWTs with a 24-hour expiry to avoid a session lookup, storing them in localStorage. Security review raises three findings: a logged-out user's token still works, a terminated employee retained access for most of a day, and an XSS in a marketing widget could exfiltrate tokens. What does the module prescribe?
A multi-tenant SaaS has tenant filters in every repository method. A data-export endpoint added during a release crunch calls the ORM directly and omits the filter; a customer downloads another tenant's records. The team's proposed fix is a code-review checklist item and a lint rule flagging direct ORM use. What does the module say is the stronger fix, and what test does it recommend?
An internal architecture has service A authenticate users and call services B and C with an X-User-Id header, which B and C trust. All three sit inside a private network with no internal TLS, sharing one static API key for a fourth service D. A reviewer calls this a blast-radius problem. What are the specific defects and the unifying rule?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
ALTERTABLE invoices ENABLEROWLEVEL SECURITY;CREATE POLICY tenant_isolation ON invoices
USING(tenant_id = current_setting('app.tenant_id')::bigint);-- Per request, inside the transaction:BEGIN;SETLOCAL app.tenant_id ='42';-- LOCAL: scoped to this transactionSELECT*FROM invoices;-- physically cannot see other tenantsCOMMIT;
// BROKEN: authenticated, therefore assumed authorised.app.get("/invoices/:id", requireAuth,async(req, res)=>{ res.json(await db.invoice(req.params.id));// whose invoice? nobody asked.});// CORRECT: the object must belong to the subject, checked in the query.const inv =await db.invoiceForTenant(req.params.id, req.session.tenantId);if(!inv)return res.sendStatus(404);// 404, not 403 — do not confirm existence