agentleFS
ashrafiucseashrafiucse/security-audit-skills/skills/auth-review/SKILL.md

auth-review

Skill2 starsChanged 50 days agoMITView on GitHub

Reviews authentication and authorization in any codebase — login flows, password storage, session/cookie handling, JWT pitfalls, OAuth/OIDC misconfigurations, IDOR/broken access control, privilege escalation, CSRF protection, route census for unguarded endpoints, trusted-header identity spoofing, check-then-act race conditions (TOCTOU), and WebSocket/SSE authorization. Use when auditing how users log in, how permissions are enforced, how internal services trust each other, or when reviewing auth-related code changes.

  • Reads credentials

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    No rate limiting / lockout on login, reset, and MFA endpoints → HIGH (note where it would live: middleware, API gateway, app code)

    line 73
  2. Very specific:

    Filter injection: search filters composed by f-string/format with user terms (f"(sAMAccountName={name})") → HIGH — * and )( characters …

    line 88
  3. Distinctive:

    Look for state checks followed by a separate write:

    line 187
  4. Very specific:

    Default-true definitions for dangerous capabilities ('import': true, "send_email": true in flag config) → High — the capability exists for …

    line 207
  5. Distinctive:

    No replay window (timestamp/event-id dedup) → High

    line 240
  6. Very specific:

    Message handlers are routes: every socket.on('cmd', ...) doing a state change needs authz.

    line 322

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Authentication & Authorization Review

1 — Map the auth architecture first

Find: login/register/password-reset endpoints, session middleware, role/permission checks, token creation/validation, OAuth client configs. Write a 3-line model: who can authenticate how → what identity looks like → where authorization is enforced. Broken access control is much easier to spot once you know the intended model.

Route census (enumerate, don't guess)

Pattern greps find what they look for; a census finds what nobody remembered. Enumerate EVERY route, then check each one:

rg -n "app\.(get|post|put|patch|delete|all|use)\("            # Express/Koa
rg -n "@app\.route\(|@\w+\.route\(|add_url_rule"              # Flask
rg -n "path\(|re_path\(" */urls.py                             # Django
rg -n "^\s*(get|post|patch|put|delete) |resources " config/routes.rb  # Rails
rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping"             # Spring
rg -n "Route::(get|post|any|resource|apiResource)" routes/     # Laravel
rg -n "@(app|router)\.(get|post|put|delete|patch)"             # FastAPI

Build a table route → auth middleware → ownership check and fill it by reading each handler. Every row with neither is a finding (Critical for admin/state-changing, High otherwise). Census-only catches:

  • routes registered BEFORE the auth middleware (order bugs — app.use(auth) must precede every guarded route)
  • the one handler missing @login_required / authorize / auth:sanctum in an otherwise-guarded file
  • non-HTTP surfaces that are routes in disguise: queue consumers, cron/background job handlers, webhook receivers, GraphQL resolvers (see ../graphql-security/SKILL.md), event subscribers, RPC/message handlers — census them with the same table
  • dispatch-style apps (?action= → switch) — enumerate the switch arms, not the single URL
  • case-sensitive middleware paths: on case-insensitive hosts/routers, app.use('/admin', guard) can be bypassed by /ADMIN or /Admin/ — check the router's case-sensitivity setting (Express: default sensitive only with regex; some frameworks/filesystems are not) and whether any guarded prefix can be cased around → authz bypass
  • encoding-sensitive middleware paths (CWE-177): authz guards comparing the RAW, still-encoded request URI (req.url / req.originalUrl, Java getRequestURI(), RAW_URI) while the router dispatches on the DECODED path — GET /%61dmin/users fails startsWith('/admin') yet still reaches the admin route → unauthenticated admin access (class incident: Cisco Catalyst SD-WAN Manager CVE-2026-76504, KEV). Safe shape: compare the decoded path the router actually dispatches on (req.path, Java getServletPath()), or mount the guard on the route itself (app.use('/admin', guard)), so percent-encoding can't split the check from the dispatch:
rg -n "req\.(url|originalUrl)\.(startsWith|endsWith|includes|match)|getRequestURI\(\)\.(startsWith|contains|equals)|RAW_URI" -g '*.js' -g '*.ts' -g '*.java' -g '*.py'
  • suffix/contains path matching in auth whitelists (third variant of the path-matching family: case → encoding → MATCHER STRICTNESS): a public-path exemption checked with path.endsWith('/x') or path.contains('/x') instead of an exact/anchored match frees EVERY colliding route, not just the intended public one (class incident: Kestra CVE-2026-49869, KEV — AuthenticationFilter used request.getPath().endsWith("/configs") to whitelist the public configs endpoint; every namespace-scoped .../configs route bypassed Basic Auth → unauthenticated API access → RCE). Safe shape: exact match set (new Set(['/configs']).has(path)) or anchored regex (^/configs$) — never suffix/substring tests on a path:
rg -n "endsWith\(\s*['\"]\/|\.contains\(\s*['\"]\/" src/ -g '*.java' -g '*.js' -g '*.ts' -g '*.py' | head
  • setup/install/first-run wizard routes: app.post('/setup/restore'), /install, /api/setup/* are unauthenticated BY DESIGN during first boot — one that stays reachable after initialization is an auth-bypass front door (class incident: ground-station CVE-2026-103244 — setup.restore invoked over Socket.IO during setup mode planted admin users and forged session tokens; full takeover). Census them with the route table; every setup route must verify installation state server-side and refuse once installed:
rg -n -i "['\"]/(setup|install|installer)[^'\"]*['\"]" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.rb' -g '*.php' | head -15
  • gRPC / protobuf services: service X { rpc Y (...) } in .proto files — every rpc is a route; check per-method auth interceptors and validate request fields like any handler. Same for message-queue consumers (Kafka/Rabbit/SQS handlers), scheduled job entry points, and Netty/WebSocket frame handlers — add them all to the census table
rg -n "rpc \w+\(" -g '*.proto'; rg -n "@GrpcClient|StreamObserver" -g '*.java' -g '*.kt'
rg -n "@(KafkaListener|RabbitListener)|consumer\.|@Scheduled|@CloudFunction|functions\." -g '*.java' -g '*.kt' -g '*.py' -g '*.js' | head

2 — Authentication checks

Password storage

rg -n -i "bcrypt|argon2|scrypt|pbkdf2|md5|sha1|sha256.*password|hashpw|crypt\("
  • bcrypt/argon2id/pbkdf2 with sane params (bcrypt cost ≥ 10, argon2id defaults or better) → OK
  • MD5/SHA1/SHA-256 for passwords → CRITICAL (unsalted fast hash = instant crack)
  • Plaintext or reversible encryption → CRITICAL

Login mechanics

  • User enumeration: login and password-reset responses differ for unknown vs wrong-password (timing or body) → MEDIUM
  • No rate limiting / lockout on login, reset, and MFA endpoints → HIGH (note where it would live: middleware, API gateway, app code)
  • Password reset: predictable token (timestamp, userid, random.random()), token reused or non-expiring, reset response leaks the token → HIGH/CRITICAL
  • MFA present? Missing MFA on admin accounts → HIGH if roles exist

LDAP authentication (enterprise directories)

rg -n "SECURITY_AUTHENTICATION|ANONYMOUS" -g '*.py' -g '*.java'
rg -n 'uid="\s*\+\s*\w+|SECURITY_PRINCIPAL.*\+' -g '*.py' -g '*.java'
rg -n '\(sAMAccountName=|\(uid=' -g '*.py' -g '*.java'
rg -n "escape_dn_chars|escape_filter_chars|LdapEncoder" -g '*.py' -g '*.java'
  • Anonymous bind offered as an auth path (authentication=ANONYMOUS, SECURITY_AUTHENTICATION set to none) → CRITICAL — everyone "authenticates"; binding ≠ verifying the intended identity
  • DN injection (CWE-90): bind DN built by concatenation — "uid=" + user + ",ou=people,..." (Python) or Context.SECURITY_PRINCIPAL concatenation (Java) → CRITICAL: a uid containing ,... or + rebinds as a different principal
  • Filter injection: search filters composed by f-string/format with user terms (f"(sAMAccountName={name})") → HIGH — * and )( characters forge wildcard/boolean filters (auth bypass + data mining)
  • Safe shape: escape before composing (escape_dn_chars() / escape_filter_chars() in ldap3, LdapEncoder.filterEncode/nameEscape in Java) — or constant service DN + post-bind attribute comparison
  • The DN/filter greps hit BOTH vulnerable and escaped forms — the escape-call context is the triage discriminator (same census discipline as XSS sinks)

Profile changes → account takeover

Email/password-change endpoints are AUTH endpoints — a session alone is not sufficient proof (stolen session, XSS-riding, insider):

rg -n "email\s*=|updateEmail|changeEmail|current_password|currentPassword|change.?password" src/ app/ | head -20
  • Email change without current-password confirmation AND without re-verification of the NEW address (verify-before-swap + notice to the old one) → High — a hijacked session silently owns the account: future password resets go to the attacker's address, no signal ever reaches the victim
  • Password change without current password → same class, High
  • Username/handle change that re-frees an old identifier while reset-by-username flows exist → Medium
  • Safe shape: requireCurrentPassword (or step-up re-auth) + verification mail to the new address before activation + revocation notice to the old one

JWT / tokens

rg -n "jwt\.(sign|decode|verify)|verify\(|algorithms|algorithm|none|HS256|RS256"
  • algorithm: "none" accepted or jwt.decode(token) without algorithm pinning → CRITICAL
  • Algorithm confusion (RS256 → HS256): jwt.verify(token, PUBLIC_KEY, {algorithms: ['HS256', ...]}) — attacker signs with the public key as HMAC secret → CRITICAL. Pin asymmetric algorithms only when the key is public; never pass a public key where the library accepts it for HMAC.
rg -n "algorithms.*HS256|verify\(.*public|createPublicKey|jwt\.RSAPublicKey" 
  • JWKS kid injection: token-controlled kid/alg/jku/x5u header flowing into paths, queries, or fetches ('./keys/' + header.kid, jwks_url + kid) → path traversal / attacker-controlled key material → Critical. Trust kid for lookup only against a fixed key set.
rg -n "header\.kid|\bkid\b.*\+|jku|x5u" 
  • PKCE: public clients (SPA/mobile/desktop — anything that can't hold a secret) using auth-code flow without code_challenge/code_verifier → code interception, HIGH. Server-side confidential clients may omit it (note, not a finding).
  • Symmetric signing with weak/default secret (secret, jwt-secret, changeme) → CRITICAL
  • No exp validation, or no aud/iss checks where multiple services share keys → HIGH
  • Tokens in localStorage with XSS present elsewhere → MEDIUM (compounding)
  • Session tokens: not rotated on login/privilege change → MEDIUM; long-lived non-revocable tokens → HIGH

3 — Authorization checks (IDOR & friends)

Find object access points, then check each for an ownership/scope test:

rg -n "req\.params\.id|findById\(|get\(|\.filter\(.*userId|user\.id|currentUser|req\.user"
  • Model.findById(req.params.id) returned directly with no where userId / post-fetch ownership check → IDOR, HIGH (CRITICAL for sensitive objects: invoices, messages, PII)
  • Association (chained) IDOR — downstream handlers fetch by a related id (?order_id=, payment_id) without verifying ownership THROUGH the join: each endpoint guards its own object, the chain leaks. Systematic method in ../flow-security/SKILL.md (class F3) — audit flows, not just routes |
  • Admin routes: how are they guarded? Missing middleware → CRITICAL. Note function-level checks too (regular user hitting /admin/* handlers).
  • Horizontal vs vertical: test reasoning for both — same-role users touching each other's data, and low-role → admin.
  • Trusting client-side hints: is_admin from request body/cookie-in-JWT-without-verify, hidden-but-served admin UI → HIGH
  • authz on derived/divergent state (ToB, Provenance 2026): when a permission decision reads a DERIVED value (cached role, denormalized count/balance, materialized flag) instead of the live source-of-truth record, two failure modes: (a) stale/replicated state authorizes what live state would deny, and (b) missing/zero/default derived state must FAIL CLOSED — a check like if derived_supply > 0: grant admin grants when the derived value is empty. Trace every authz read to its source-of-truth write; verify the default branch denies.

Mass assignment (generic)

The framework skills cover Laravel/Django/Rails fillable semantics; the generic version bites every ORM:

rg -n "\.create\(\s*req\.body|\.update\(\s*req\.body|Object\.assign\(\s*\w+\s*,\s*req\.body|new \w+\(\s*req\.body|findOneAndUpdate\(\s*\w+,\s*req\.body|bulkCreate\(\s*req\.body"

Request body spread into create/update without an allowlist → attacker sets role, isAdmin, userId, credits, price, email (account takeover via email change). Report High — Critical when the model has role/permission fields. Safe: explicit field destructuring/pick, schema-level allowlist ($fillable, fields), validators configured to strip unknown keys (default validate/zod .strict() — not passthrough). Also check filter/map "sanitizers" that only check types: an allowLIST of keys, not of types, is what counts.

Trusted-header authorization (identity spoofing)

rg -n -i "req\.headers\[\s*['\"]x-|request\.headers\.get\(|getHeader\(\s*\"X-|headers\[\s*'X-|x-forwarded-for|x-forwarded-user|x-real-ip|HTTP_X_"

Using X-User-Id / X-Email / X-Admin / X-Forwarded-For as the identity or authz input means anyone who can reach the service directly is any user → CRITICAL. Valid only when BOTH hold: (1) an edge proxy/gateway strips these headers from inbound traffic, and (2) the app is not directly reachable (check NodePort/LoadBalancer services, ingress annotations, port exposure in compose). Microservice meshes are the classic miss: service B trusts X-User-Id "because only service A calls us" — but anything on the cluster network can. Headers used only for logging (X-Request-Id) are fine — trace the value into an authz decision before flagging.

SSO/OIDC callback host verification (CWE-943). Rocket.Chat GHSL-2026-004/005: an account/SSO service accepted callbacks whose host did not match the configured/allowed one → authentication bypass. Wherever an OIDC/OAuth/SAML/OmniAuth callback or JWKS/discovery URL is resolved from configuration, verify the code checks the exact host/origin (allowlist, not substring/endsWith) and rejects mismatches before trusting the identity assertion → Critical (direct auth bypass).

Authorization predicate on a request-derived principal (sibling of header spoofing — the write side). The capability/role check itself is evaluated against a user object CONSTRUCTED FROM REQUEST DATA instead of the authenticated session principal: user_can(get_user_by('id', $_COOKIE['original_user_id'])), findById(req.cookies.user_id).can(...), hasRole(req.body.role). The check "passes" for whoever the REQUEST names — forging the cookie/param IS the privilege. DevKit Pro CVE-2026-14378 shape: user-switching revert checked manage_options on the cookie-named user instead of current_user_can() → admin takeover. → Critical. The principal for any authz decision must come from the session/token verification path; request data may name a TARGET, never the ACTOR.

rg -n "findById\(\s*req\.(cookies|body|query)|get_user_by\([^)]*\$_(COOKIE|GET|POST|REQUEST)|user_can\s*\(\s*\$" src/ app/ -g '*.js' -g '*.php'

Multi-tenant scoping census (SaaS)

Per-object IDOR checks miss the systematic version: in a multi-tenant app, EVERY data access must carry the tenant filter — one query without it leaks a whole tenant's data to another tenant's users (often via list/export/report endpoints that feel "shared").

rg -n "tenant|org_?id|account_?id|customer_?id|workspace" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.rb' -g '*.php'   # tenant model census — size it (wc -l), then read
rg -n "\.(find|where|filter|all|select|query|get)(All)?\(" -g '*.js' -g '*.py' | rg -v "tenant|org_|account_|customer_|user"   # unscoped reads — census: disposition EVERY line

Method: list every model access, mark each SCOPED (tenant filter present) / UNSCOPED / GLOBAL-BY-DESIGN (shared catalog). Every UNSCOPED access to tenant-owned data → Critical. Stronger defenses to note when present: DB-level row-level security (RLS), ORM global scopes (Laravel global scope, Django manager), middleware-injected tenant context that queries MUST use. Also check: tenant taken from request body/header instead of session (req.body.tenantId — attacker-controlled scope switch → cross-tenant, Critical), and cache keys missing the tenant prefix (cross-tenant cache bleed).

  • Target-tenant vs caller-tenant on credential-issuing endpoints: when a request names a TARGET (user_id, email, login_name) and the handler issues enrollment codes / invitations / reset URLs / passkey or passwordless registrations for that target, checking only the CALLER's request tenant (org header, subdomain) while never comparing the TARGET's tenant = cross-tenant takeover: an admin (or any user-write holder) in org A obtains enrollment credentials for org-B users and registers their own authenticator (class incident: ZITADEL CVE-2026-105209 — passkey enrollment codes authorized against x-zitadel-orgid while the target user's organization went unchecked, cross-org ATO, CVSS 9.6). The authz must compare the TARGET's tenant with the caller's tenant (or verify the caller's scope covers the target's tenant) — validating the request's own tenant header proves nothing about the target. Strongest shape: scope the target lookup itself by the caller's session tenant (findOne({ id, org_id: session.org })) rather than fetch-then-check:
rg -n -i "enroll(ment)?.?code|invite.?(code|user)|passwordless" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java' | head

Check-then-act races (TOCTOU)

Look for state checks followed by a separate write:

rg -n "if\s*\(.*\b(used|redeemed|approved|active|enabled|stock|balance|remaining)\b|\.findOne\(.*\)\.then|SELECT.*\b(balance|stock|used)\b.*FROM|\.save\(\)"
  • Single-use coupons/tokens/reset links: if (t.used) reject; ... t.used = true → parallel replay wins every time → High (Critical for payments/refunds/webhooks). Fix: atomic conditional write — findOneAndUpdate({code, used:false}, {$set:{used:true}}), UPDATE ... SET used=1 WHERE code=? AND used=0 checking rows affected.
  • Balance/stock read-modify-write: user.balance -= amt; user.save() → double-spend under concurrency. Fix: UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?.
  • Webhooks/event handlers without idempotency keys or event-id dedup → replayable. Report as one grouped finding with every affected flow listed.

Feature-flag & plan-capability gating

Flags constrain CAPABILITY (what a plan/tenant/role may do), not identity — an authenticated admin can still be the attacker the plan must stop. UI/menu gating is cosmetic; the handler/route is the enforcement point. Incident class (2026-09-23): a trial tenant blasted 1200+ emails through a compose route whose flag was enforced only in the menu and the queued job.

rg -n "Feature::(define|active)|isFeatureEnabled|featureFlags|feature_flags|isEnabled\(|isEnabledFor|\.variation\(" src/ app/ modules/
rg -n "pennant:purge|flushCache|flag.*purge" deploy/ scripts/ .github/

Stack-agnostic census (framework skills carry the concrete greps — e.g. ../laravel-security/SKILL.md Step 8):

  • For every flag whose name implies a dangerous capability (import, email, send, sms, export, invite, admin, billing): WHERE is it enforced? Checks only in UI/menu code or only inside async jobs = gated-in-the-wrong-layer → High; Critical when the ungated surface fans out outbound messages (F9, ../flow-security/SKILL.md)
  • Default-true definitions for dangerous capabilities ('import': true, "send_email": true in flag config) → High — the capability exists for everyone including trials; per-plan closures/lookups are the safe shape
  • Flag-store staleness: persisted flag stores (Pennant database, DB-backed Unleash/OpenFeature, a features table) keep stored per-scope values when defaults change — reverting a default changes nothing for already-stored scopes. Deploy scripts must purge/sync; absence → Medium
  • Public endpoints that TRIGGER OUTBOUND messages (send-verification, subscribe, reset, notify, magic-link, webhook-register): census them with §1's route table; each needs auth-or-signed + throttle (+ captcha where public) → Critical when public AND unthrottled: ID enumeration = mail/SMS bomb needing zero auth and zero flags

One-time codes (verification / login / MFA codes)

A one-time code IS a bearer credential — census every endpoint that PRODUCES or VERIFIES one, then check WHERE the code goes and WHAT ELSE the verifier accepts:

rg -n -i "verification.?code|login.?code|one[-_ ]?time.?code|\botp\b|magic.?code|sms.?code|email.?code" src/ app/ routes/ | head -40
rg -n -i "(master|static|backdoor|debug|test).{0,12}code" src/ app/ config/ db/ seeds/ -g '*.sql' -g '*.java' -g '*.py' -g '*.js' -g '*.ts' -g '*.yml'
  • Producer endpoint whose HTTP RESPONSE carries the generated code (res.json({ ..., code }), put("code", ...), response wrapper, template rendering it) → Critical — unauthenticated takeover of any known identifier: request the code, read it from the response, log in (class incident: CVE-2026-97063, X-SpringBoot — GET /sys/mobile/code and /sys/email/code returned codes; login via /sys/emailOrMobileLogin)
  • Static/master code the verifier accepts (seed rows, config constants, "test-mode" fallbacks — X-SpringBoot's 172839 seed, CVE-2026-97064; entry: ../cve-research/vuln-db/entries/2026-09-25-cve-2026-97064.md) → Critical — a public backdoor credential; if it ever shipped, assume it IS used
  • Code delivered to an address/number taken from the REQUEST instead of the stored account record → High — attacker-controlled delivery channel harvests codes
  • Weak hygiene: reusable codes, no expiry, no attempt cap on verify, predictable generators (Math.random) → Medium→High by brute-force feasibility
  • Safe shape: CSPRNG code, sent ONLY to the stored channel, response = {sent: true} (existence-neutral), single-use + short TTL + verify throttle + constant-time compare, NO static fallback in any environment — dev seeds get cloned to prod

Webhook receiver authentication

Webhook endpoints are authenticated by the SENDER's signature, not by your sessions — an unverified receiver is an unauthenticated endpoint with money/entitlement powers. Census every webhook/callback route with §1's table, then:

rg -n "webhook|callback" src/ app/ routes/ | rg -i "post|route" | head -20
rg -n "timingSafeEqual|compare_digest|verify_sig|verifySignature|signature" src/ app/
rg -n "!process\.env\.[A-Z_]*SECRET|WEBHOOK_SECRET" src/ app/ .env.example 2>/dev/null
  • Handler parses req.body/event and ACTS (fulfill, enroll, activate, refund) without verifying the sender's HMAC signature → Critical (forged events = free entitlements; pairs with F4 replay in ../flow-security/SKILL.md)
  • Signature compared with ==/=== instead of constant-time (timingSafeEqual, hmac.compare_digest) → Medium (timing oracle)
  • Signature computed over re-parsed JSON instead of the RAW request bytes → verification fails → commonly "fixed" by deleting the check (drift toward Critical)
  • Verification gated on the secret being SET (if (!WEBHOOK_SECRET) next() / empty-secret guard that skips the check) → fail-open on missing config → Critical — the Dockhand CVE-2026-53988 shape: null webhook secret guard → unauthenticated stack redeploy, and the redeploy pulls an attacker-controlled docker-compose.yml (privileged bind mounts) = container escape. A missing/empty secret must REJECT (fail closed), never skip verification.
  • No replay window (timestamp/event-id dedup) → High
  • Callback that ESTABLISHES a session (not merely acts): the handler authenticates the CALLER from the payload — wp_set_auth_cookie()/wp_set_current_user() on a request-supplied user id, req.login(user) where user came from a GET/POST param (base64 blob, JSON field). There is no signature to fall back on: identity itself arrives from the attacker, so a forged callback = logged in as anyone → Critical. Divi Membership CVE-2026-19660 shape: process_paypal_callback on init, base64 paypal_param GET param, no IPN/signature/ownership/nonce → wp_set_auth_cookie(). Census every session-establishment call and trace where its user comes from:
rg -n "wp_set_auth_cookie\s*\(|wp_set_current_user\s*\(|req\.login\s*\(|\.logIn\s*\(" src/ app/ -g '*.js' -g '*.ts' -g '*.php'
  • Safe shape: the callback verifies the sender FIRST and resolves the member/user from the VERIFIED event's stored record (order → user), never from a request parameter; raw-body HMAC + constant-time compare + event-id dedup, all before any side effect

API keys as authentication (service-to-service)

Machine auth has its own failure modes — check every API-key/gateway-token scheme:

rg -n -i "x-api-key|api[_-]?key" -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.go' -g '*.rb' | head
  • Key in URL/query (?api_key=) — hits logs, referrers, browser history → HIGH (same class as tokens in URLs)
  • Unscoped keys — one god-key instead of per-service/per-permission keys: lateral movement on any leak → MEDIUM/HIGH
  • No rotation story — no expiry, no revocation endpoint, keys older than the repo's history → MEDIUM (operational)
  • Constant-time comparison absent (== on the key) → timing oracle → LOW/MEDIUM (pairs with crypto-review comparison rules)
  • Weak key generation — uuid(), random.random(), timestamp-derived → predictable keys → HIGH (see crypto-review randomness table)
  • Key = password reuse — same key validates AND encrypts/signs → separation of duties violation → MEDIUM
  • Non-secret identifiers as credentials — authenticating a device/user by a value that is public or enumerable (hostname, hardware serial, username, email, machine-id) is authentication theater: anyone who knows the identifier IS the identity (class incident: Fleet CVE-2026-103264 — device API accepted hostnames and hardware serials as tokens alongside UUIDs; unauthenticated actors became any iOS/iPadOS host, MDM migration included). Identifiers are for LOOKUP, never for proof — enroll with a one-time secret or client cert, then authenticate on that:
rg -n -i "findby(hostname|serial|hardware)|by_(hostname|serial)|token\s*={2,3}[^=]*(hostname|serial|machine.?id)" -g '*.js' -g '*.ts' -g '*.py' -g '*.go' -g '*.java'
  • Internal keys honored as master auth on every route (UTMStack CVE-2026-82042 class: InternalApiKeyFilter accepted the Utm-Internal-Key header == INTERNAL_KEY env var for ANY endpoint — no path restriction, no constant-time compare, no rate limit, no audit logging — so anyone who obtains the infra key gets full admin API: account creation, user management, rule changes): an internal/service perimeter key that the PUBLIC surface also accepts is a god-key with extra steps — the perimeter that justified the key does not exist at the app. The key must (1) be honored only on internal routes/hosts, (2) compare constant-time, (3) grant a SCOPED service identity (never admin), and (4) be obtainable only by internal callers — if any less-privileged endpoint reveals it, the scheme is Critical regardless of the rest:
rg -n -i "internal[_-]?key|x-internal|internal[_-]?api[_-]?key" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java'

4 — Session & CSRF

  • Cookie flags on session/auth cookies: HttpOnly (missing + XSS present → HIGH), Secure (missing → HIGH if prod is HTTPS), SameSite (missing → MEDIUM)
  • CSRF: state-changing endpoints protected by token/SameSite=Strict/Origin check? Both auth-cookie AND no CSRF defense → HIGH
  • Logout invalidates server-side session? Not just client cookie clear → MEDIUM
  • Session fixation: session ID regenerated after login → check

5 — OAuth/OIDC, if present

  • redirect_uri validated exactly (not prefix/suffix match) → open redirect_uri = account takeover, CRITICAL
  • state parameter present and verified → missing = CSRF/login CSRF, MEDIUM/HIGH
  • Token in URL fragment vs query (query leaks via logs/referrers) → MEDIUM
  • id_token validated: signature, nonce, aud → skipping any = CRITICAL
  • Account linking (external IdP identity → local account) without caller verification: census every surface that BINDS an external-IdP identity to a local account (AddIDPLink, /auth/link, link_account, social connect). A linking call that acts on a caller-SUPPLIED login name / user identifier — without an authenticated session plus a fresh primary factor or proof of control of BOTH identities — lets anyone who knows a victim's login name attach their OWN IdP identity and then sign in as the victim (class incident: ZITADEL CVE-2026-105207 — User Service V2 AddIDPLink + identify-only Login V2 sessions, unauthenticated ATO, CVSS 9.8). → Critical. Safe shape: link only to the session principal, require step-up re-auth, and take the external identity from the IdP's verified assertion (never from request fields):
rg -n -i "add_?idp|idp.?link|link.?(account|identity)|connect.?(account|identity)" src/ app/ -g '*.js' -g '*.ts' -g '*.py' -g '*.java' -g '*.go' -g '*.php' | head -20

Host-header poisoning in outbound auth/email flows

Password-reset/verification links and OAuth redirect URIs built from Host / X-Forwarded-Host (request-derived) let an attacker poison the victim's link domain (CodeQL class; Django's ALLOWED_HOSTS=['*'] is the classic enabler — see ../django-security/SKILL.md Step 1). Grep link/URL construction in mailers and auth controllers for request-header inputs → HIGH (account takeover via poisoned reset links).

6 — SAML (if present)

rg -n -i "saml|assertion|NameID|acs[ _-]?url|AudienceRecipient|samlp" -g '*.py' -g '*.java' -g '*.rb' -g '*.php' -g '*.js' -g '*.xml' | head -15

The four classic SAML bugs, in observed frequency order:

  • Signature wrapping (SWA/XSW): the response's Signature element covers one assertion but the app reads a DIFFERENT one (attacker-injected). Defense: validate the signature over the exact node you consume (response.get_assertion().validate_signature? no — one_assertion_only + signature-verified read). Grep for one_assertion_only / want_assertions_signed settings — absent → HIGH/Critical.
  • Comment injection in NameID: admin@corp.com<!--atk-->@evil.com parses as admin@corp.com to the IdP-signed value but the SP extracts differently — mitigated by library versions and NameID format checks; flag any custom NameID string handling → HIGH.
  • Unvalidated recipients: Destination/AudienceRestriction/ACS URL not enforced (or ACS taken from the assertion!) → assertion replay across SPs. Config keys: expected_audience, acs_url pinned server-side → absent → Critical.
  • Loose validation knobs: want_response_signed=False + want_assertions_signed=False, clock skew ±24h (not_before/not_on_or_after neutered), allow_unsolicited in prod → each HIGH.

Safe shapes: pinned ACS + audience, both signed flags true, one-assertion-only, tight skew (≤5m), library current (python3-saml / OneLogin / passport-saml families each had historic CVEs — version-check via vuln-db/OSV).

7 — Realtime channels (WebSocket / SSE)

rg -n "io\.on\(|socket\.on\(|new WebSocket|WebSocketServer|@MessageMapping|@SubscribeMapping|STOMP|EventSource|ActionCable|cable\.|channel\.subscribe"
  • Handshake auth: no io.use(authMiddleware) / token check on connect → CRITICAL (any client connects as anyone)
  • Subscription authz: socket.join('room:' + id) / channel subscribe without ownership check → IDOR over sockets, High — same rule as REST IDOR, different transport
  • Message handlers are routes: every socket.on('cmd', ...) doing a state change needs authz; grep the handler bodies exactly like controllers
  • Command-forwarding handlers are RCE surfaces (UTMStack CVE-2026-82041 class: STOMP /command/{hostname} destination → processCommand() forwarded arbitrary OS commands over gRPC to agents, no role check, no allowlist — any authenticated user, any role, root/SYSTEM on monitored hosts): a realtime channel whose messages become COMMANDS executed on backends/agents must enforce (1) role check per handler — authentication ≠ authorization, "any logged-in user" is not a policy — and (2) a server-side command allowlist (verb + target scope) before anything reaches the executor. Generic shape: message destination → agent.send({cmd}), grpc.invoke, exec(/spawn( inside the handler → Critical (remote command execution on downstream hosts):
rg -n "socket\.on\(\s*['\"][^'\"]*(command|cmd|exec|run)|@MessageMapping\(\s*['\"][^'\"]*(command|cmd|exec)" src/ app/ -g '*.js' -g '*.ts' -g '*.java'
rg -n "(agent|host)\.(send|execute|invoke)\(" src/ app/ | head
  • io.origins('*') / missing origin check on a credentialed socket → Medium (cross-site WebSocket hijacking)
  • SSE (EventSource endpoints) are plain GETs — same authz as any other route

Reporting

For each finding: severity, file:line, one-line exploit story ("authenticated user A reads user B's invoice by changing ?id="), and the concrete fix (ownership scope in query, middleware, flag). Group IDORs by endpoint pattern — they usually cluster.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.