Auditing Authentication and Authorization
Two words that get confused at the worst times
Authentication answers “who are you?” Authorization answers “what are you allowed to do?” Every engineer knows the distinction. And yet the single most common serious finding in the codebases we audit is code that nails the first question and never asks the second.
It happens because the two problems have opposite shapes. Authentication is centralized: one login flow, one session mechanism, usually one library. Get it right once and you’re mostly done. Authorization is distributed: it has to be correct at every endpoint, every query, every background job, hundreds of small decisions written by different people over years. Centralized problems get solved; distributed problems decay.
An audit treats them accordingly: authentication gets a deep review of a few critical paths, authorization gets a broad sweep of everything. Here’s what both look like.
Auditing authentication: a few paths, reviewed hard
Password handling
The checklist is short and non-negotiable: passwords hashed with bcrypt, argon2, or scrypt (not MD5, not SHA-256, not “SHA-256 with a salt”; fast hashes are the problem, salted or not); a work factor that isn’t the library default from 2015; and no maximum-length limit under ~64 characters (a tell that someone is storing, not hashing).
The subtler finding is timing: a login flow that returns in 5ms for unknown emails and 250ms for known ones (because bcrypt only ran in the second case) is a user-enumeration oracle. The fix is to hash a dummy password on the unknown-user path too.
Session and token mechanics
For cookie sessions: HttpOnly, Secure, and SameSite set; session ID regenerated on login (session fixation); sessions actually destroyed server-side on logout.
For JWTs, the audit reads the verification code character by character, because JWT bugs are all one-liners:
// Vulnerable: decode() does NOT verify the signature
const payload = jwt.decode(token);
// Vulnerable: algorithm not pinned, downgrade attacks
jwt.verify(token, secret);
// Correct
jwt.verify(token, secret, { algorithms: ['HS256'] });Plus: is the secret strong and from the environment (see how secrets leak into repos)? Is there an expiry, and is it checked? Is there any revocation story, or does “log out” mean “the token works for another 30 days”?
The forgotten flows
Login gets reviewed; its side doors don’t. Password reset is the big one: a reset token is a temporary password, and it needs the same rigor: single-use, short-lived, invalidated when a new one is issued, compared in constant time, and never logged. Then: email-change flows (can I change the email, then reset the password, and fully hijack the account?), OAuth callbacks (is state validated?), “remember me” tokens, and API keys (hashed at rest, or plaintext in the database?).
Rate limiting
No throttle on login means credential stuffing at line speed. No throttle on reset-token attempts means a 6-digit code is brute-forceable in hours. This is the least glamorous control in the chapter and the one most often missing entirely.
Auditing authorization: everything, systematically
Authentication review is depth. Authorization review is coverage, and it’s organized around three questions.
Question 1: Is every object access scoped to its owner?
Broken object-level authorization (IDOR) is the most common critical finding in real audits. The pattern is always the same:
# Vulnerable: authenticated ≠ authorized
@app.get("/api/documents/{doc_id}")
def get_document(doc_id: int, user: User = Depends(auth)):
return db.get(Document, doc_id)
# Fixed: ownership enforced in the query
@app.get("/api/documents/{doc_id}")
def get_document(doc_id: int, user: User = Depends(auth)):
doc = db.query(Document).filter(
Document.id == doc_id,
Document.account_id == user.account_id,
).first()
if doc is None:
raise HTTPException(404)The auditor’s method is mechanical: list every route that takes an ID, then verify the ownership check exists on each one, including the UPDATE and DELETE variants, which are more often missed than the reads, and the nested resources (/projects/1/tasks/999: is the task checked against the project, or just the project against the user?).
Question 2: Is every privileged function enforced server-side?
Hiding the admin button is not access control. The audit finds every privileged endpoint and asks what happens when a regular user calls it directly with curl. Special attention goes to the non-HTTP surfaces where role checks are most often absent: background jobs, webhook handlers, GraphQL mutations (each resolver needs its own check; the gateway can’t do it), and internal admin tooling that “only we know the URL to.”
Question 3: Can tenant data cross the boundary?
For multi-tenant SaaS, this is the existential one. The strongest codebases enforce tenancy structurally (a query layer that refuses to run without a tenant scope, or Postgres row-level security) so that correctness doesn’t depend on every developer remembering every time. The weakest sprinkle WHERE account_id = ? by convention, which means the audit must ask: where did someone forget? The usual answers: search endpoints, CSV exports, reporting queries, and anything written under deadline pressure. (Deadline-pressure code is where all kinds of debt concentrates; see technical debt explained.)
Why humans, not scanners
Notice what unites nearly every finding above: the bug is a missing check, not a bad line. db.get(Document, doc_id) is flawless code in a single-user app and a critical vulnerability in a multi-tenant one. A scanner has no idea which app it’s reading. An auditor builds a model of your data (who owns what, what roles exist, where the tenant boundary is) and then verifies the code against the model. That’s not something you can regex for, and it’s most of why this part of an audit takes real time.
A self-check before anyone audits you
- Pick your five most sensitive endpoints. For each: log in as user A, request user B’s object by ID. 404? Or data?
- Call an admin endpoint as a non-admin, directly, skipping the UI.
- Read your JWT verification line. Is the algorithm pinned? Is expiry enforced?
- Request two password resets. Does the first token still work?
- Grep your handlers for queries that filter by
idalone with no owner/tenant column.
Twenty minutes, and in our experience at least one of those five produces a surprise.
Get a free code audit
This review (the full authn deep-dive and the endpoint-by-endpoint authz sweep) is the core of the free audit we offer. Genuinely free: a Webisoft engineer manually reviews your repository and sends you a written report of what we found, ranked by risk, with concrete fixes. If your access control has never had a second pair of eyes on it, get a free code audit. The “log in as user A, fetch user B’s data” test is the first thing we’ll run.