Security

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 id alone 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.

FAQ

Frequently asked questions

What is the practical difference between authentication and authorization?

Authentication establishes who a user is; authorization decides what that user is allowed to do. Authentication is centralized (one login flow, one session mechanism, usually one library), so it tends to get solved once. Authorization has to be correct at every endpoint, query, and background job, which is why it decays over time and why an audit sweeps it broadly instead of deeply.

What is the most common critical authorization bug?

Broken object-level authorization, often called IDOR: an endpoint checks that you are logged in but never checks that the record you requested belongs to you. The fix is to enforce ownership in the query itself, filtering by the user's account as well as the record ID. The update and delete variants, and nested resources, are missed more often than plain reads.

Why do JWT implementations go wrong so often?

Because the bugs are one-liners that look correct. Calling decode instead of verify skips signature checking entirely, and verifying without pinning the algorithm leaves room for downgrade attacks. Add weak secrets, missing expiry checks, and no revocation story, and you have the full set of findings auditors read for character by character.

Can a security scanner find these access control problems?

Mostly no. The typical finding is a missing check, not a bad line: the same one-line query is flawless in a single-user app and a critical vulnerability in a multi-tenant one. Catching that requires a model of who owns what data and where the tenant boundary sits, which is something a human builds and a regex does not.

How can I test my own access control before getting audited?

Log in as user A and request user B's objects by ID on your most sensitive endpoints, call an admin endpoint as a non-admin directly with curl, and check whether an old password reset token still works after a new one is issued. Twenty minutes of this usually produces at least one surprise. If you want a second pair of eyes afterward, this exact sweep is the core of the free audit.

Free · no strings shown later

Get your free code audit

A repo URL and two minutes of your time. A Webisoft engineer sends back written findings (security, tech debt, performance) with file-and-line specifics.

Request my free audit