websec · level 2107

Auth Bypass

IDOR, JWT confusion, open redirects, mass assignment — the broken-access-control class.

250 XP

Auth Bypass — The Broken Access Control Class

Authentication says "I know who you are." Authorization says "I know what you're allowed to do." Most production breaches sit in the gap: the user is authenticated correctly; the system fails to ask "but is this their data?"

OWASP A01:2021 — Broken Access Control — was the #1 web application risk for that year and the year before. The bugs in this class are old, simple, and shipped daily.

IDOR — Insecure Direct Object Reference

The classic. The route accepts an id; the server queries by that id; nobody asked whether the logged-in user owns the resource.

GET /users/42/profile          ← Alice, logged in as user 41
       └─ The server returns user 42's profile because the query is
          SELECT * FROM users WHERE id = 42.
          The auth check confirmed Alice is logged in. The auth*orization*
          check that says "user 41 may read user 42" was missing.

The fix is one line:

SELECT * FROM users WHERE id = $req_id AND owner_id = $current_user_id

Or in an ORM:

db.user.findFirst({
  where: { id: req.params.id, ownerId: req.user.id }
})

For shared resources (a doc with multiple readers), replace ownerId with a join to a permissions table. The principle holds: every query that reads a user-supplied id must also constrain on the current user's permissions.

In the OWASP API Top 10, this is called BOLA (Broken Object Level Authorization) — same bug, API-flavored.

JWT Bypasses

Three classic flavors:

alg=none

The JWT spec defines an alg claim in the header that says how the token was signed: HS256, RS256, etc. It also defines none — a literal "this token is unsigned." Many libraries pre-2017 accepted none by default. An attacker constructed {"alg":"none"}.{"sub":"admin"}. (no signature) and was admin.

The fix:

jwt.verify(token, key, { algorithms: ["RS256"] });
//                       ^^^^^^^^^^^^^^^^^^^^^^^^ pin to a non-empty list

Modern libraries refuse none by default, but always pass algorithms: explicitly anyway.

Key confusion (RS256 → HS256)

The server is set up with an RSA public key for verification: jwt.verify(token, PUBLIC_KEY). The attacker sends a JWT with alg=HS256. The library tries to HMAC-verify with PUBLIC_KEY as the secret. Since the public key is, well, public, the attacker can also HMAC-sign with it. The token verifies.

The fix is the same — pin the algorithm:

jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"] });

Stolen / accepted-without-revocation tokens

Covered in the session-management lesson. Short version: revocation paths matter; "JWTs can't be revoked" is a half-truth.

Open Redirects

A route like:

GET /redirect?to=https%3A%2F%2Fphishing.example

…that 302s the user to whatever URL they supply. The URL appears on your-trusted-site.com/redirect?... and links from your site are trusted by users and sometimes by mail filters. Phishers love them.

The fix:

const ALLOWED = new Set(["app.example.com", "dashboard.example.com"]);

app.get("/redirect", (req, res) => {
  const url = new URL(req.query.to as string, "https://x");
  if (!ALLOWED.has(url.hostname)) return res.status(400).send("not allowed");
  res.redirect(req.query.to);
});

Or for in-app navigation, accept a path (not a URL): /redirect?to=/dashboard. Reject anything that contains ://, //, or \\.

Mass Assignment

// Express + Mongoose
app.put("/me", authRequired, async (req, res) => {
  Object.assign(req.user, req.body);    // ← BUG
  await req.user.save();
  res.json(req.user);
});

The user POSTs { name: "Alice", role: "admin" }. The server happily assigns role and saves. Alice is admin.

The same shape exists in Rails (update_attributes(params) without permit), Django REST Framework (default Serializer with all model fields), and ASP.NET MVC (model-binding without an explicit allowlist).

The fix is an explicit allowlist:

const ALLOWED = new Set(["name", "email", "avatarUrl"]);

const updates = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => ALLOWED.has(k))
);
Object.assign(req.user, updates);

Or use a DTO library (Zod, Yup, class-validator) that strips unknown keys.

Never Object.assign(model, req.body). Treat all input as hostile by default.

A few more in this class

Forced browsing

Predictable URLs for admin pages: /admin/dashboard, /internal/audit-log. No auth check (or only client-side hidden links). Anyone who guesses the URL gets in.

The fix: server-side authorization checks on every route. Don't rely on "the link isn't in the nav."

Privilege escalation via parameter tampering

A JSON body that includes role, subscription_tier, feature_flags — fields the user shouldn't be able to set. (Mass assignment specialized for privilege escalation.)

Path traversal in authorization

GET /files/../../etc/passwd
GET /files/%2e%2e%2f..%2fetc/passwd

…served by a route that joins user-supplied path with a base directory without canonicalization. Always canonicalize and verify the resolved path stays within the allowed root.

Verb tampering

A route like /api/account/delete checks auth on POST but allows GET (no auth check on the GET handler). Or a CORS misconfig allows DELETE without preflight.

The unifying principle

Every request is authorized on its own merits. There is no such thing as "this user is logged in, so they can do anything." Every read, every write, every state change asks: does this user have permission to do this thing to this resource?

A typical sensitive route looks like:

app.delete("/projects/:id", authRequired, async (req, res) => {
  // 1. Authentication confirmed by middleware.
  // 2. Authorization is THIS function's job:
  const project = await db.project.findFirst({
    where: { id: req.params.id, ownerId: req.user.id, deletedAt: null }
  });
  if (!project) return res.status(404).end();    // 404 to avoid enumeration
  // 3. Now do the action:
  await db.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } });
  await audit({ action: "project.delete", actor: req.user.id, target: project.id });
  res.status(204).end();
});

Three properties:

  • The query reads + checks ownership in one round-trip — no TOCTOU.
  • Failure returns 404, not 403, to avoid revealing which IDs exist.
  • The audit log records who did what so anomalies are detectable.

Testing

For every authenticated route, run two tests:

  1. As a different user: expect 403 or 404. Most IDOR bugs fail this test in seconds.
  2. As an unauthenticated user: expect 401. Catches "auth-check-was-only-on-the-link" bugs.

Tools: Burp Suite + Autorize plugin, OWASP ZAP, Stark — all replay requests with swapped sessions and flag mismatched responses. Bake one of these into your CI and you'll catch most of this class before it ships.

In production

Most of the high-impact breaches of the last 10 years were broken access control: 2018 USPS Informed Visibility (IDOR exposed PII for 60M users), 2018 Panera (IDOR — emails of millions), 2019 Snapchat (mass-assignment bug), 2021 Parler (no auth on the storage URL — entire user data publicly accessible), 2023 Uber (IDOR + mass assignment combo). The problem is not theoretical.

Three rules:

  1. Authorize at the data layer, not the route layer. Every query includes the current user as a constraint.
  2. Lock JWT verification to a specific algorithm; never accept none; never use the same key for HS and RS verification.
  3. Allowlist input — never Object.assign(model, req.body), never redirect(req.query.url), never trust user input to define authorization scope.

Get those right and most of OWASP A01 disappears.

Tools in the wild

3 tools