Most breaches are not about breaking encryption; they are about logging in. A phished password, a token that never expires, a service account with admin rights nobody remembers granting. Identity and access management is where those doors are, and it is the control that pays back fastest when done well. This module separates the two questions every request must answer (who are you, and what may you do), shows how modern web authentication actually works with sessions, tokens, OAuth 2.0 and OpenID Connect, and lays out the least-privilege habits that keep a compromised identity from becoming a compromised company.
- Distinguish authentication from authorisation and place each correctly in a request's life
- Explain how sessions, bearer tokens and JWTs carry identity, and the trade-offs between them
- Describe the OAuth 2.0 authorization code flow with PKCE and what OpenID Connect adds
- Choose MFA methods by phishing resistance and apply them where they matter most
- Design roles and policies that follow least privilege, for humans and for services
Two questions, two failures
Authentication (authn) answers "who are you?" and produces an identity: a user id, a service name. Authorisation (authz) answers "is this identity allowed to do this to that?" and produces a yes or no. They fail differently. A weak authentication lets a stranger become someone; a weak authorisation lets a legitimate someone do things they should not. The single most common web vulnerability for years has been broken authorisation: an endpoint checks that you are logged in, then forgets to check that the record you asked for is yours.
The clean design puts authentication at the edge once (a gateway or middleware validates the credential and attaches an identity to the request) and authorisation next to every data access, where the code knows what the resource is and who owns it. Authorisation that lives only in the UI ("the button is hidden") is not authorisation; the API can be called directly.
def get_order(order_id: int, caller: User) -> Order:
# Wrong: fetch, then hope someone checks ownership later
# order = db.get(Order, order_id)
# Right: the identity is part of the lookup, so a foreign id simply does not exist
order = db.query(Order).filter(Order.id == order_id, Order.user_id == caller.id).first()
if order is None:
raise NotFound() # 404, not 403: do not confirm that the id exists
return orderReturn the same 404 whether the record does not exist or belongs to someone else. A 403 tells an attacker that they guessed a valid id.
Sessions, tokens and where identity travels
After a successful login the server needs a way to recognise the user on later requests. Two families exist. A session is a random opaque id stored in a cookie; the server keeps the session data (user id, expiry) in a store and looks it up per request. Revocation is trivial (delete the row) and nothing sensitive leaves the server. A bearer token, typically a JWT, carries the claims itself, signed by the issuer; any service with the public key can verify it without a lookup, which scales across many services but makes revocation hard — the token is valid until it expires.
| Property | Server session (cookie) | Signed token (JWT) |
|---|---|---|
| Revocation | Immediate: delete the session | Only via short expiry or a denylist |
| Cross-service use | Needs a shared session store | Verified anywhere with the public key |
| What leaks if stolen | An opaque id | The claims, readable by anyone |
| Typical lifetime | Hours to days, sliding | Minutes; refreshed with a refresh token |
For cookies, set HttpOnly (JavaScript cannot read it), Secure (HTTPS only) and SameSite=Lax or Strict (blocks most cross-site request forgery). For tokens, keep access tokens short-lived (five to fifteen minutes), issue a longer-lived refresh token that can be revoked, and store tokens where cross-site scripts cannot reach them. In browsers that usually means an HttpOnly cookie rather than local storage. Whatever carries identity, treat it as a credential: never in URLs, never in logs.
A JWT's payload is base64, not encrypted. Anyone who sees the token can read the claims. Put identifiers in it, not secrets or personal data you would not want in a log.
OAuth 2.0 and OpenID Connect
OAuth 2.0 is a delegation protocol: it lets an application obtain limited access to a user's resources at another service without ever seeing the user's password. OpenID Connect (OIDC) is a thin layer on top that turns the same flow into authentication, adding an ID token (a JWT describing the user) and a standard userinfo endpoint. When you "sign in with" a corporate identity provider, that is OIDC.
The flow you should use for web and mobile applications is the authorization code flow with PKCE. PKCE (Proof Key for Code Exchange) binds the code to the client that started the flow, which closes the code interception attacks that made the older implicit flow unsafe.
- The app generates a random
code_verifier, hashes it into acode_challenge, and redirects the browser to the identity provider's authorize endpoint with the challenge, the requested scopes, astatevalue and itsredirect_uri. - The user authenticates at the provider (password, MFA, whatever policy applies). The provider redirects back to the
redirect_uriwith a short-lived, single-use authorization code and the samestate. - The app checks
statematches what it sent (this is the CSRF protection), then exchanges the code plus the originalcode_verifierat the token endpoint, over TLS, for an access token, an ID token and usually a refresh token. - The app validates the ID token: signature against the provider's published keys,
iss,aud(must be this app's client id),exp, and thenonceif one was sent. Only then is the user considered signed in.
1. Browser -> Identity provider
GET /authorize?response_type=code&client_id=web-app&scope=openid%20profile%20email
&redirect_uri=https://app.example.com/callback&state=k3j2...&code_challenge=E9Mr...
&code_challenge_method=S256
2. Identity provider -> Browser -> App
GET https://app.example.com/callback?code=SplxlOBe...&state=k3j2...
3. App (server side) -> Identity provider
POST /token grant_type=authorization_code&code=SplxlOBe...&redirect_uri=...
&client_id=web-app&code_verifier=dBjftJeZ...
<- { access_token, id_token, refresh_token, expires_in: 600 }Scopes describe what the access token may do (read:orders), and the resource server enforces them; a token with the wrong audience or missing scope must be rejected. Use a maintained library for every step, and register exact redirect_uri values at the provider: a wildcard redirect is an open door for token theft. Machine-to-machine calls use the client credentials grant (no user involved), and CI systems increasingly use OIDC federation to obtain cloud credentials without stored keys, which the DevOps track covers under secrets.
MFA, passwords and phishing resistance
Passwords will be phished, reused and leaked. Multi-factor authentication adds something the attacker does not have, but factors differ enormously. SMS codes can be intercepted by SIM-swapping and relayed by phishing pages in real time. Time-based one-time codes (TOTP apps) resist SIM swaps but not real-time phishing: the victim types the code into the fake page and the attacker uses it within thirty seconds. Push notifications suffer from MFA fatigue, where the attacker triggers prompts until the victim taps approve. Only phishing-resistant methods — FIDO2 security keys and platform passkeys — bind the login to the genuine site's origin, so a lookalike domain receives nothing usable.
- Require MFA on everything reachable from the internet: email, source control, cloud consoles, VPN, the identity provider itself.
- Prefer passkeys or security keys for administrators and anyone with production access; allow TOTP as a fallback, avoid SMS.
- Enforce number matching or similar on push approvals to blunt fatigue attacks.
- For passwords: minimum length (12+), check against breached-password lists, no forced periodic rotation, no composition rules that produce
Summer2026!. - Rate-limit and alert on failed logins per account and per source; lock out slowly, not instantly, to avoid self-inflicted denial of service.
Recovery flows are part of authentication. A strong login with a "reset via email" path means your security equals your email security, and a support desk that resets MFA over the phone is the attacker's favourite entry point.
Least privilege for humans and machines
Least privilege means every identity has exactly the permissions its current task needs, and no more. It limits the blast radius when (not if) a credential is stolen. In practice you achieve it with role-based access control (RBAC): permissions are attached to roles, roles to identities, and both are reviewed. Attribute-based rules (time of day, device posture, resource tags) refine it where roles are too coarse.
Machines need identities too, and they are where privilege creep hides. A CI runner with admin because a deploy failed once; a service account shared by six services because nobody wanted to create seven. Give every workload its own identity, scope it to the resources it touches, prefer short-lived credentials obtained at runtime (cloud instance roles, Kubernetes service account tokens, OIDC federation) over long-lived keys, and log every use so anomalies stand out.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadInvoicesForThisService",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::acme-invoices", "arn:aws:s3:::acme-invoices/2026/*"],
"Condition": {"Bool": {"aws:SecureTransport": "true"}}
}
]
}Grant temporarily where you can: just-in-time elevation for production access, approved and time-boxed, is far safer than standing admin rights. Review access on a schedule, and treat a role nobody can explain as a bug. When someone leaves or changes team, their access should change the same day; the identity provider should be the one place where that happens, with every system federated to it.