Skip to content

Identity, auth and security

Sessions, tokens, OAuth, and the mistakes that become incidents.

01

Authentication and authorisation

beginner

Authentication establishes who you are. Authorisation decides what you may do. They fail differently, they are fixed differently, and they are confused constantly in both code and conversation.

Most serious access-control bugs are authorisation bugs. The user is correctly identified, the session is valid, and the system simply never checks whether this particular user may touch this particular record. Changing an identifier in a URL and seeing someone else's data has sat near the top of the OWASP list for years, and it is not a clever attack; it is a missing line.

The reason is structural rather than careless. Authentication happens once, in one place, at the edge, so it is nearly impossible to forget. Authorisation happens on every request against every resource, in every handler, so forgetting exactly one is the default outcome as a codebase grows. Counting on discipline across two hundred endpoints is not a security model.

The durable fix is to make the check impossible to skip by moving it to where the data is fetched. A repository whose queries always carry the tenant or owner constraint cannot return another tenant's row, whatever the handler above it forgot. Row-level security in the database is the strongest version of the same idea, since it holds even for a query someone writes by hand during an incident.

Then there is the model itself. Role-based access control assigns permissions to roles and roles to users, which is simple and gets awkward when the answer depends on the object rather than the person: the same editor may edit their own drafts and not someone else's. Attribute-based rules describe the condition instead, and relationship-based systems, like Google's Zanzibar, store who is related to what and answer questions about the graph. Most products start with roles and grow a relationship model whether or not they name it.

Two rules survive every model. Deny by default, so a missing rule refuses rather than permits, and check on every request rather than only when rendering a link, because the button being hidden is not a control when the endpoint is still open.

Check yourself

A logged-in user changes an id in the URL and sees another customer's invoice. What failed?

fetch invoicefetch invoicescopedstill scopedcannot return anothertenantRequestauthenticatedHandler Aremembers to checkHandler BforgotRepositorytenant always in thequeryDatabaserow-level security
ClientServiceData storeDashed = asynchronousConsidered, not chosen
Put the check where the data is, not in every handler
Evidence · 2 sources
  1. 01
    OWASP Top Ten 2021: A01 Broken Access Control

    That authorisation failures, including changing an identifier to reach another user's record, rank first by prevalence rather than being an exotic attack.

  2. 02
    MITRE CWE-1345: OWASP Top Ten 2021 category A01

    The same ranking, from a second independent index, along with the specific weaknesses OWASP grouped under it.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

02

Sessions or JWTs

intermediate

A session id is a reference: an opaque random string that means nothing on its own. The server holds the state it points at, can change or delete it instantly, and pays a lookup on every request. That lookup is a cache hit in practice, measured in a fraction of a millisecond, which is worth remembering when statelessness is being justified on performance grounds. A JWT carries its claims and a signature, so any service holding the public key can validate it without asking anyone. That is the entire feature, and the cost follows directly from it: a token that validates locally cannot be revoked locally, because revocation is shared state and shared state is what the design removed. A user who logs out, an employee who is dismissed, a token that leaks: each stays valid until it expires.

The usual compromise is a short-lived access token, minutes rather than hours, alongside a longer-lived refresh token that is stored server side and can be revoked. That is a session with extra steps, and it is the right answer when the validating services genuinely cannot reach a shared store. It is worth being honest that the revocation window is now the access token lifetime, and choosing that number is choosing how long a compromised token keeps working.

Where the token is stored matters as much as its format. A cookie with HttpOnly is unreadable by JavaScript, which removes the main consequence of a cross-site scripting bug, and needs SameSite and a CSRF defence because the browser attaches it automatically. Local storage is readable by any script on the page, so one compromised dependency takes every token, and it is chosen mainly because it is convenient for a single-page application.

The claims themselves need care. Verify the signature before reading anything, reject the none algorithm outright, check the issuer and audience so a token minted for another service is not accepted by yours, and keep the payload small because it travels on every request and is readable by anyone holding it. A JWT is signed, not encrypted; putting anything private in it publishes it.

Said plainly: for one application talking to a database it already has open, a session is simpler, revocable and better. JWTs earn their keep when the validator cannot reach your store, across services, across companies, or at an edge with no database at all.

Check yourself

Why can't you immediately revoke a standard JWT?

referencevalueone lookupany holder of the keyrevocablenot revocableRequestcarries one or theotherSession idopaque, means nothingaloneJWTsigned claims,self-containedSession storea sub-millisecondlookupVerified locallyno lookup, norevocationLogout is instantdelete the recordValid until expirythe window is thelifetime
ClientServiceData storeExternalDashed = asynchronousConsidered, not chosen
A reference the server can revoke, or a claim it cannot
Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

03

OAuth and OpenID Connect

advanced

OAuth is delegated authorisation: it lets an application act on a user's behalf without ever holding their password. The user is sent to the provider, approves a specific scope, and the application receives a token that grants exactly that. It is not a login protocol, and the many systems that treat it as one have a class of vulnerability in common.

OpenID Connect is the thin layer on top that adds identity. Alongside the access token it returns an ID token, a signed JWT stating who the user is, which issuer authenticated them, which client it was issued for and when. Sign in with Google is OIDC. Inferring identity from an access token instead is the classic mistake, because an access token proves the bearer may call an API and says nothing about who the bearer is.

The flow worth knowing in detail is authorisation code with PKCE, which is now the recommendation for every client type rather than only for mobile. The application redirects the user with a challenge derived from a secret it keeps, the provider returns a short-lived code through the browser, and the application exchanges that code plus the original secret for tokens over a back channel. The code alone is useless to anyone who intercepts it, which is what PKCE exists to guarantee.

Two parameters carry most of the remaining security. The redirect URI must be matched exactly against a registered value, because a wildcard or a loose prefix lets an attacker have the code delivered to a host they control. The state parameter must be generated, sent and verified on return, since it is the cross-site request forgery defence for the callback and skipping it is easy because everything works without it.

The implicit flow, which returned tokens directly in the URL fragment, is deprecated for good reason: tokens ended up in browser history, in referrer headers and in server logs. If you find it in an existing integration, that is a finding rather than a style preference.

Scopes deserve a moment of thought rather than a copied list. Ask for the narrowest scope that does the job, because the consent screen is where users decide whether to trust you and because a token stolen from you can do whatever it was granted. An integration that requests full account access to read a profile is both a security risk and a conversion problem.

Check yourself

An integration needs the user's name and email. Which scope request is appropriate?

sign inchallenge, exact redirectshort-lived codereturned to the appcode plus verifierUserredirectedApplicationkeeps the verifierProviderauthenticatesCodevia the browserTokensback channel only
ClientServiceExternalData storeDashed = asynchronous
Authorisation code with PKCE: the code is useless without the verifier
Evidence · 1 source
  1. 01
    RFC 9700: best current practice for OAuth 2.0 security

    That PKCE is recommended for all clients including confidential ones, that the implicit grant should not be used, and that redirect URIs must be compared by exact string match.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.

04

Secrets and encryption

intermediate

Secrets belong in a manager with rotation and an audit trail, not in environment variables committed to a repository or baked into an image. The property that matters is not secrecy alone but revocability: when a credential leaks, the question is how quickly it can be replaced and how confidently you can tell what it touched. A secret nobody can rotate without a deploy is a secret that will not be rotated.

Assume every secret leaks eventually, and design for the aftermath. Short lifetimes, one credential per service rather than one shared everywhere, and scoped permissions all shrink what a leak costs. Scanning for committed secrets is worth having, and rotating anything a scanner finds is the only correct response, because a secret that reached a repository has reached everyone who cloned it.

Passwords are a separate problem with a settled answer. Hash them with an algorithm designed to be slow and memory-hard, Argon2id by preference, bcrypt or scrypt where it is not available, with a per-user salt that the algorithm handles for you. Never a fast general-purpose hash such as SHA-256: a modern graphics card tries billions of those a second, so the speed that makes it a good checksum makes it a poor password hash.

Encryption in transit is table stakes and essentially free. Encryption at rest is worth less than people assume: it defends against a stolen disk and it satisfies an auditor, and it does nothing at all against an attacker holding valid application credentials, which is how data actually leaves. Field-level encryption for the few genuinely sensitive columns, with keys held separately, is the version that changes an attacker's outcome.

Key management is the part that decides whether any of it holds. Keys in the same store as the data they protect are decoration. A key management service, an envelope scheme where a data key is encrypted by a master key, and a documented rotation path are the difference between encryption as a control and encryption as a checkbox. Finally, secrets leak through channels nobody calls a secret store: log lines, error messages, exception trackers, URL query strings, and the analytics tool watching the page. Redacting at the logging boundary, and keeping credentials out of URLs, closes the most common route, and it is cheaper than discovering a token in a third-party tool's search index.

Check yourself

What property of a secrets manager matters most in practice?

wraps itencrypts the valueif stored togetherredact at the boundaryKey managementholds the master keyData keyencrypted by themasterEncrypted fieldstored beside thedataKey beside the datadecoration, not acontrolWhere it leakslogs, URLs, errortrackers
ServiceData storeExternalDashed = asynchronousConsidered, not chosen
Envelope encryption, and the channels a secret leaks through anyway
Evidence · 1 source
  1. 01
    OWASP password storage cheat sheet

    That Argon2id is the first recommendation, with scrypt and bcrypt as alternatives, and that fast general-purpose hashes are unsuitable.

Ask about this

Answers are generated and can be wrong. The topic above is the reviewed version.