Area: Security, identity and access
Related: Microsoft Graph, PostgreSQL, WebSocket, OAuth 2.0, OpenID Connect, JWT, Sessions and Cookies

What is it?

Authentication (authn) answers “who is making this request?”. Authorization (authz) answers “is this caller allowed to do this specific thing to this specific resource?”. They run in that order on every request, and they fail differently.

Authentication ends with an identity: a user id, a service account, a tenant id, maybe a list of claims. Authorization takes that identity plus the action plus the resource and returns yes or no.

Authentication turns a request into a principal. Authorization turns (principal, action, resource) into allow or deny.

The two get blurred because one artifact often carries both. A JWT proves who you are (signature, sub) and also carries hints about what you may do (scope, roles). That doesn’t make them the same step. A valid token says nothing about whether user 42 can read invoice 9001.

Why the split matters

Most real breaches in web apps aren’t broken login. They’re broken authorization: the user is correctly logged in and then reads someone else’s data. “Broken Access Control” has sat at the top of the OWASP Top 10 since the 2021 edition.

The reason is structural. Authentication happens once, in one place, usually in middleware or a library you didn’t write. Authorization happens everywhere: every route, every query, every background job, every file download URL. Each new endpoint is a new chance to forget it.

So treat them as two separate systems with separate code, separate tests and separate failure modes.

AuthenticationAuthorization
QuestionWho are you?May you do this?
InputCredentials, cookie, tokenPrincipal + action + resource (+ context)
OutputPrincipal (or nothing)Allow / deny
WhereOnce, at the edgeAt every decision point
HTTP failure401 Unauthorized403 Forbidden (or 404)
Typical techPasswords, passkeys, OIDC, SAML, mTLS, API keysRBAC, ABAC, ReBAC, RLS, policy engines
Common bugNot verifying a token properlyForgetting to check ownership (IDOR)

The request pipeline

request
  → authenticate: read cookie / Authorization header, verify, load principal
      fail → 401 + WWW-Authenticate
  → authorize: can(principal, action, resource)?
      fail → 403 (or 404 to hide existence)
  → handler

In code, keep them as two separate pieces:

// authn: produces a principal or nothing. Never decides access.
async function authenticate(req: Request): Promise<Principal | null> {
  const header = req.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) return null;
  const claims = await verifyAccessToken(header.slice(7)); // throws on bad token
  return { userId: claims.sub, tenantId: claims.tid, roles: claims.roles ?? [] };
}
 
// authz: pure decision. Takes everything it needs as arguments.
function canEditDocument(p: Principal, doc: Document): boolean {
  if (doc.tenantId !== p.tenantId) return false;
  if (doc.ownerId === p.userId) return true;
  return doc.editors.includes(p.userId);
}
 
app.patch("/documents/:id", async (req, res) => {
  const principal = await authenticate(req);
  if (!principal) return res.status(401).set("WWW-Authenticate", "Bearer").end();
 
  const doc = await db.documents.findById(req.params.id);
  if (!doc || !canEditDocument(principal, doc)) return res.status(404).end();
 
  // ... apply the edit
});

Keeping canEditDocument pure (no I/O, no request object) makes it trivial to unit test with a table of cases. That’s where most of your security tests should live.

401 vs 403, properly

The names are historical and misleading. 401 Unauthorized really means unauthenticated.

  • 401: “I don’t know who you are, or your credentials are bad or expired.” RFC 9110 says a 401 response must include a WWW-Authenticate header naming the scheme (Bearer, Basic, …). Clients react by logging in again or refreshing a token.
  • 403: “I know exactly who you are. The answer is no.” Retrying with the same credentials won’t help. Clients should not trigger a re-login loop on 403.
  • 404 instead of 403: when the existence of the resource is itself private. If /orgs/acme/invoices/9001 returns 403 for outsiders and 404 for nonsense ids, an attacker can enumerate which invoices exist. GitHub returns 404 for private repos you can’t see for this reason.

A frontend that treats 401 and 403 the same way ends up either logging users out when they click a button they can’t use, or silently swallowing expired sessions.

With OAuth bearer tokens (RFC 6750) the header can carry detail: WWW-Authenticate: Bearer error="invalid_token" for 401, and error="insufficient_scope", scope="files.write" with a 403.

Authentication mechanics

Proving identity

MechanismWhat’s actually provenNotes
PasswordKnowledge of a shared secretStore with argon2id, scrypt or bcrypt. Never a fast hash.
TOTP / SMS codePossession of a device or numberSMS is weak to SIM swap. TOTP is phishable.
Passkey (WebAuthn)Possession of a private key bound to the originPhishing resistant because the browser binds the signature to the site’s origin.
OIDC / SAML loginAnother system authenticated you and signed a statement saying soYou trust the identity provider’s signature, not a password.
API keyPossession of a long random stringNo user, no expiry by default. Hash it at rest like a password.
mTLSPossession of a client certificate’s private keyCommon for service to service.

Carrying identity between requests

After login you don’t want to re-prove identity on every request. Two broad styles:

Server sessions. Login creates a row (session_id → user_id, expires_at) and sets a cookie holding a random id. Each request looks up the row.

  • Revocation is instant: delete the row.
  • Costs a lookup per request (cheap with Redis or a cached Postgres row).
  • Cookie should be HttpOnly; Secure; SameSite=Lax (or Strict). HttpOnly stops JavaScript, and therefore XSS, from reading it.

Self-contained tokens (usually JWT). Login returns a signed token containing the claims. Each request verifies the signature, no lookup.

  • No shared store needed, which is why APIs and microservices like them.
  • Revocation is hard. A stolen token is valid until exp. That’s why access tokens are short (minutes) and paired with a refresh token that is checked against a store.

Neither is more secure by default. Sessions are simpler for a single web app. Tokens make sense when a different service from the one that logged you in needs to verify you.

Verifying a JWT without fooling yourself

A JWT is base64url(header).base64url(payload).signature. Decoding is not verifying. Anyone can write a payload saying "sub": "admin".

import { createRemoteJWKSet, jwtVerify } from "jose";
 
const JWKS = createRemoteJWKSet(new URL("https://login.example.com/.well-known/jwks.json"));
 
async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://login.example.com/",   // who signed it
    audience: "api://my-api",               // who it was minted FOR
    algorithms: ["RS256"],                  // pin it, never trust the header
    clockTolerance: 30,                     // seconds of skew
  });
  return payload;
}

Every option there blocks a real attack:

  • algorithms: the alg: none trick, and the RS256 → HS256 confusion where the attacker signs with HMAC using your public key as the secret. Old libraries that read alg from the token header fell for both.
  • issuer: a token from a different identity provider (or a different tenant) with a valid signature from its keys.
  • audience: a token minted for some other API that happens to trust the same issuer. Without this check, any app that can get a token from your IdP can call your API with it.
  • exp / nbf: checked by default in most libraries. Make sure they are.
  • Key rotation: JWKS endpoints publish several keys with kids. Cache them, and refetch when you see an unknown kid. A rotation at the IdP otherwise means an outage for you.

OAuth is authorization, OIDC is authentication

This one confuses nearly everyone, because “Sign in with X” uses both.

OAuth 2.0 is a delegation protocol. A user lets an app act on their behalf at some API, limited by scopes. The app gets an access token. The access token is meant for the resource server (the API). The app isn’t supposed to read it, and in some providers it’s opaque.

OAuth on its own never told the app who the user was. People hacked it by calling a /me endpoint with the access token, which led to real bugs: an access token issued to app A could be replayed at app B’s login, and B would happily log the attacker in as the victim.

OpenID Connect fixes that by adding an ID token: a JWT with aud set to the client app, plus sub, iss, nonce. That’s the authentication part.

TokenAudiencePurposeWho reads it
ID tokenThe client app”This user just authenticated”The app, once, at login
Access tokenThe API”This caller may do these things”The API, on every call
Refresh tokenThe authorization server”Give me a new access token”Only the auth server

Rules that follow:

  • Don’t send ID tokens to APIs as bearer tokens.
  • Don’t use access tokens as proof of login in your app.
  • A scope like files.read means the user allowed the app to read files. It does not mean this user can read that file. The API still checks per-resource permission. Effective access is the intersection of what the user can do and what they delegated.

Authorization models

RBAC: role based

Users get roles, roles get permissions.

const permissions: Record<Role, Set<Permission>> = {
  viewer: new Set(["doc:read"]),
  editor: new Set(["doc:read", "doc:write"]),
  admin:  new Set(["doc:read", "doc:write", "doc:delete", "member:invite"]),
};
const can = (p: Principal, perm: Permission) => p.roles.some(r => permissions[r].has(perm));

Good for coarse, tenant-wide rules. Breaks down when access depends on which resource. People then invent editor_of_project_17 roles and the role table explodes.

ABAC: attribute based

Decide from attributes of the principal, resource, action and environment.

function canApproveExpense(p: Principal, e: Expense, now: Date) {
  return p.department === e.department
      && p.approvalLimit >= e.amount
      && e.submittedBy !== p.userId        // no approving your own
      && now.getUTCHours() < 22;
}

Flexible, but rules spread out and become hard to audit (“who can see this record?” requires evaluating every rule against every user).

ReBAC: relationship based

Access comes from a graph of relationships: user → member of → team → editor of → folder → parent of → document. This is the model behind Google’s Zanzibar paper (2019) and open-source systems inspired by it like OpenFGA and SpiceDB.

document:readme#viewer@folder:eng#viewer
folder:eng#viewer@team:platform#member
team:platform#member@user:alice
→ alice can view document:readme

It fits sharing UIs (Google Drive style) naturally. The cost is running and keeping a separate permission store consistent with your main database. Zanzibar’s paper spends a lot of space on the “new enemy” problem: you remove Bob from a folder, then add a secret doc, and a stale replica still lets Bob read it. Their answer is consistency tokens (“zookies”) that carry a snapshot timestamp.

Picking one

  • Start with RBAC scoped to a tenant (member of org X with role editor).
  • Add ownership and explicit sharing as resource-level checks when users share individual things.
  • Reach for ReBAC when sharing becomes nested (folders, teams, inheritance) and the ad-hoc checks start disagreeing with each other.
  • Use ABAC rules for the few business constraints that really depend on data (limits, time, status).

Most apps end up as a mix, and that’s fine as long as the decision lives in one module.

Where to enforce it

In the query, not after it. Filtering after fetching leaks through counts, pagination and timing, and someone will eventually forget the filter.

// bad: fetch then check, and list endpoints usually skip the check
const doc = await db.query("select * from documents where id = $1", [id]);
 
// better: the tenant and access are part of the lookup
const doc = await db.query(
  `select d.* from documents d
   where d.id = $1 and d.tenant_id = $2
     and (d.owner_id = $3 or exists (
       select 1 from document_shares s where s.document_id = d.id and s.user_id = $3))`,
  [id, principal.tenantId, principal.userId],
);

Postgres Row Level Security pushes this into the database so even a forgotten where can’t leak rows:

alter table documents enable row level security;
 
create policy tenant_isolation on documents
  using (tenant_id = current_setting('app.tenant_id')::uuid);
 
-- per request, inside a transaction:
set local app.tenant_id = '6f1c...';

Traps with RLS: table owners and superusers bypass it unless you alter table ... force row level security, connection poolers in transaction mode require set local (not set) so the value doesn’t leak to the next client, and policies on large tables need indexes on the columns they filter by.

Never in the frontend only. Hiding the delete button is UX. The API must still refuse. Anything the browser sends can be edited.

Background jobs and webhooks. A job enqueued with { documentId } runs with no request and no principal. Either store the acting user and tenant in the payload and re-check, or document clearly that the job runs as a trusted system identity and validate inputs.

Multi-tenant and delegated access

In SaaS the first authorization question is almost always “is this the right tenant?”. Most cross-customer leaks are a missing tenant_id condition, not a missing role.

  • Put tenant_id on every row, derive it from the authenticated principal, and never accept it from the request body or URL without checking membership.
  • When your app calls another API as the user (for example reading their files through Microsoft Graph with a delegated token), the remote API does its own authorization. That’s helpful, but you still need your own check that the user in your app is linked to that remote account.
  • Confused deputy: your server holds powerful credentials (an app-only token, a storage key). A user asks it to fetch driveId=X. If you pass X through without checking that this user may see X, you’ve lent your credentials to them. Every privileged backend call needs an authz check against the original caller.

Performance and caching

  • Authn with sessions costs one lookup. With JWTs it’s a signature check, which for RS256 is fast to verify (verification is much cheaper than signing). Cache the JWKS, not the result.
  • Authz on lists is the expensive part. Checking each of 500 rows individually is an N+1 problem. Push the rule into SQL, or ask your ReBAC system for a “list objects this user can view” query rather than 500 checks.
  • Caching decisions is where revocation goes to die. If you cache “alice can edit doc 9” for 5 minutes, removing Alice takes 5 minutes to apply. Decide that window on purpose and keep it short for sensitive actions.
  • Claims in a JWT are a cache too. If roles are baked into a 1 hour token, demoting someone takes up to an hour unless you check a revocation list or re-read roles from the database.

A real-world example

A collaborative document app for teams, with a web client, a REST API and a sync server:

  • Login is OIDC against the customer’s identity provider. The app validates the ID token once, then creates a server session with an HttpOnly cookie. The browser never holds a long-lived token.
  • Every API route runs the same middleware: session → { userId, tenantId, role }. Routes that skip it are listed in one allowlist file reviewed in PRs.
  • Document access is ownership plus explicit shares plus workspace role, all expressed in one documentAccess(principal) SQL fragment that every query joins on. RLS on tenant_id sits underneath as a second net.
  • The sync WebSocket authenticates with a short-lived ticket in the first message (browsers can’t set headers on WebSocket), and the server re-checks document access on join and whenever sharing changes, kicking clients who lost access.
  • Calls to a third-party file API use the user’s delegated token, so the provider enforces its own permissions too. The app also checks that the requested file belongs to a folder linked to this workspace, which stops one tenant from pointing the app at another tenant’s files.
  • Responses use 401 for expired sessions (client redirects to login) and 404 for documents the user can’t see, so ids can’t be probed.

Gotchas

  • 401 means unauthenticated, 403 means unauthorized. And a 401 without WWW-Authenticate breaks spec-following clients.
  • Decoding a JWT is not verifying it. jwt.decode() in a request path is a bug.
  • Not checking aud lets tokens for other apps work on yours.
  • Scopes limit the app, not the user. files.read doesn’t mean “any file”.
  • IDOR: /invoices/9001 works for any logged-in user because the handler only checked “is logged in”.
  • Mass assignment: PATCH /users/me { "role": "admin" } succeeds because the ORM copies every field. Allowlist writable fields.
  • Tenant id from the request body instead of the session.
  • Authorization in the UI only. The API is the boundary.
  • Long-lived JWTs with embedded roles make demotion and logout slow.
  • Background jobs and admin scripts run with no principal and quietly skip every check.
  • Login and “forgot password” responses that differ for existing and non-existing emails leak which accounts exist.
  • RLS bypassed by the table owner role your app happens to connect as.
  • Comparing secrets or API keys with === can leak timing. Use crypto.timingSafeEqual on equal-length buffers (or compare hashes).

Practice

  1. Write authenticate() and authorize() as separate functions for a small Express or Hono API. Make a route return 401, 403 and 404 in the right cases and test each with curl -i.
  2. Hand-craft a JWT with alg: none and another signed with HS256 using your public key. Confirm your verifier rejects both, then remove the algorithms option and see what an older library does.
  3. Build an IDOR on purpose: /notes/:id checking only login. Exploit it with two users, then fix it by moving the ownership check into the SQL where.
  4. Add Postgres RLS on tenant_id with set local. Connect as the table owner and watch it bypass the policy. Fix it with force row level security and a non-owner role.
  5. Model folder → document inheritance with sharing in OpenFGA or SpiceDB. Write the “list documents alice can view” query and compare it with the equivalent recursive SQL CTE.
  6. Add a 5 minute decision cache. Revoke access and measure how long the user keeps it. Then add cache invalidation on share changes.

Sources worth reading

  • RFC 9110, HTTP Semantics, sections on 401, 403 and WWW-Authenticate
  • RFC 6749 (OAuth 2.0) and RFC 6750 (Bearer Token Usage)
  • RFC 8725, JSON Web Token Best Current Practices
  • OpenID Connect Core 1.0 (ID token validation rules)
  • OAuth 2.0 Security Best Current Practice (RFC 9700)
  • OWASP Top 10, A01 Broken Access Control, and the OWASP Authorization and Authentication cheat sheets
  • Zanzibar: Google’s Consistent, Global Authorization System (USENIX ATC 2019)
  • PostgreSQL docs, Row Security Policies
  • jose library docs (jwtVerify, createRemoteJWKSet)