# What Passport Found in 3 Weeks of AI Agent Traffic

> Source: <https://blog.postman.com/what-passport-found-in-3-weeks-of-ai-agent-traffic/>
> Published: 2026-09-24 16:00:44+00:00

# What Passport Found in 3 Weeks of AI Agent Traffic

Every API call needs a credential, and for as long as software has called APIs, the way we’ve handed credentials to the thing making the call has been to copy them. Into `.env`, into a bash profile, into a credential helper, into the OS keychain. Postman’s research found that on average, each live secret appeared in roughly eight different locations on the same machine.

Your laptop isn’t the end of it, either. Think about the last time a teammate was blocked on a key. It got pasted into a Slack thread, or typed into an onboarding doc, or emailed, or caught in a screenshot of someone’s working config. Every one of those is a normal Tuesday afternoon, and every one of those is permanent. Slack doesn’t know it’s holding a credential. A Google Doc can’t rotate one. Nobody goes back and edits a screenshot. So the number of places a given key lives only ever goes up.

Now, we’ve all worked like this for years and mostly been fine, and that’s not luck. It worked because a human was the one picking every API call. You wrote the request, so you knew which key it used and roughly where it was going, and if something looked off, you were sitting right there watching it. The copies were everywhere, but you were the one spending them.

Agents take that away. An agent decides while it’s running which tools to call and which hosts to hit, and every process it starts inherits your environment variables whether it needs them or not. So a key you exported for one task is now reachable by code you didn’t write, making calls to endpoints you didn’t choose. That’s the actual shift. Not that agents make more requests than we do, though they will, and Postman’s estimate is 1,000x. The shift is that nobody can tell you up front where a credential is going to end up. Handing out copies of a secret only works while you know who’s holding them, and that’s the part [Postman Passport](https://blog.postman.com/postman-passport-secure-api-access-for-the-agentic-era/) goes after: stop giving every consumer its own copy, and give it scoped permission to make the call instead.

I wanted to know what that actually looks like in practice, on a working developer machine. So I ran the experiment on my own.

I spent the last three weeks building an AI harness to measure what the Context Graph API does to an agent’s token consumption. On day one I turned on Postman Passport and then went on to building.

Three weeks later, when I finally analyzed the dashboard, Passport had counted 22 distinct secrets leaving this laptop across 33 requests, headed for 18 different hosts, in 8 different formats, sent by 17 different user agents.

My `.env` file had four entries in it.

That comparison is the point. `.env` is the file developers treat as their credential inventory: when someone asks what secrets a project uses, it’s what you open. Mine accounted for fewer than a fifth of the credentials this machine actually authenticated with. The other eighteen weren’t stolen or hidden. They came from the OS keychain, from git’s credential helper, from npm’s config, and from environment variables that every subprocess inherits. All legitimate places to keep a key. None of them inventoried anywhere.

Passport measured this same sprawl from the storage side and found roughly eight copies per live secret. I measured it from the traffic side and found five times more credentials in use than I could name.

I didn’t write any of those requests. I ran an agent, the agent chose its tools, and the tools chose their network calls. The execution path didn’t exist until runtime, so there was no file I could have opened beforehand to predict it. My static scanner stayed green the entire time, and it was right to: not one of those 22 secrets was in the repository.

That’s the gap. A secret scanner tells you where credential material is **stored**. Nothing in that toolchain tells you where credentials **travel**. And the answer isn’t better storage. It’s the shift Passport is built around: **invert the secret sharing model into an access control model, so that instead of distributing secrets to API consumers, you grant controlled and granular access to consume APIs.**

Here’s the summary the dashboard opened with:

| Metric | Count | 
|---|---|
| Secrets detected | 22 | 
| Destinations | 18 | 
| Secret types | 8 | 
| User agents | 17 | 
| Total findings | 33 | 

*[SCREENSHOT: Passport dashboard summary and findings table]*

**Eight secret classes showed up**: bearer tokens, `apikey` headers, `x-api-key` headers, Basic auth credentials, AWS authorization credentials, JWTs, session IDs in query parameters, and signature query parameters.

## Why your credential inventory is incomplete

A credential allows access to a resource for a program/person. Your laptop is full of them. Some sit in a file, some in the operating system’s keychain, some in a config for a tool you installed two years ago and forgot about.

For most of the history of software the security question was **where are they kept**, and that question had a satisfying answer. You put the key somewhere, so you knew where it was. Scanning tools grew up around that assumption: point them at your code, and they tell you whether a key is sitting somewhere it shouldn’t.

Agentic development breaks that assumption in a specific way, and it’s worth being precise about the mechanism rather than hand-waving at “AI is scary.”

When you give an agent a task, it works out how to do that task while it is running. It picks tools. Those tools run other programs. Those programs open network connections. Each layer hands its environment down to the next one, and credentials ride along in that environment by default:

``` php
you state a goal
  -> the agent picks a tool
    -> the tool runs a CLI or an SDK
      -> that program opens an HTTP connection
        -> a credential goes on the wire
```

Nobody wrote that chain down. It assembled itself at runtime, which is why the storage question stopped being sufficient. **You can audit every file you own and still have no idea which programs used which keys to talk to whom.**

Four findings in my report make that concrete. I’ll go through each with the actual data, because the aggregate counts are the headline and the specifics are the lesson.

## Finding 1: four credentials were in URLs, not headers

Of everything in the report, these are the four I would fix first.

```
release-assets.githubusercontent.com   curl/8.7.1        JWT               ••••
release-assets.githubusercontent.com   curl/8.7.1        sig query param   ••••
sdmntprsouthcentralus.oaiusercontent.com  codex_sdk_ts/0.147.0  sig query param  ••••
mcp.atlassian.com                      claude-code (cli)  sessionid query param  ••••
```

Put plainly: a program talking to a server can carry its password in one of two places. It can go in a header, which is delivery metadata that mostly isn’t written down, or it can go in the web address itself, which is written down almost everywhere. Four of my requests used the second one.

Look at the first two rows. Same host, same client, same second: `17:02:58`. One request carried both a JWT and a signature parameter in the query string. That’s a presigned asset URL, and functionally it’s a bearer capability with a timestamp on it. Anyone holding the full URL has the access.

[RFC 6750 section 2.3](https://datatracker.ietf.org/doc/html/rfc6750#section-2.3) is unusually blunt about why this is a problem. Bearer tokens “SHOULD NOT be passed in page URLs,” because of “the high likelihood that the URL containing the access token will be logged.” The spec lists browser history, web server logs, and referrer headers as the leak paths. The third path is [documented on MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer): unless a site sets a strict referrer policy, the full URL including its query string travels to the next host the browser touches.

None of that is news. What’s new is *who is constructing these URLs now*. I didn’t write the `codex_sdk_ts` call, and I didn’t write the `curl` invocation that pulled a release asset. An agent did, following a redirect chain I never saw.

So why is a header safer than a URL, when both travel over the same encrypted connection to the same server? The answer is not encryption. It’s what gets written down at the other end.

Send the same secret twice, once each way, and look at the raw request:

```
curl -s -o /dev/null --trace-ascii - \
  -H "Authorization: Bearer SECRET-IN-HEADER" \
  "https://postman-echo.com/get?access_token=SECRET-IN-URL" 2>&1 \
  | grep -iE "GET /get|Authorization:"
0000: GET /get?access_token=SECRET-IN-URL HTTP/1.1
006b: Authorization: Bearer SECRET-IN-HEADER
```

Both secrets arrive. The difference is that the first line is the HTTP request line, and the request line is the part servers keep. The [Common Log Format](https://en.wikipedia.org/wiki/Common_Log_Format) that Apache, nginx, and most reverse proxies write by default records the request line, the status, and the byte count. In [Apache’s format strings](https://httpd.apache.org/docs/2.4/mod/mod_log_config.html) that field is `%r`; in [nginx’s default combined format](https://nginx.org/en/docs/http/ngx_http_log_module.html) it’s `$request`. Neither one records `Authorization`.

So the secret in the query string gets persisted, in plaintext, by every server, proxy, load balancer, and CDN on the path, plus whatever ships those logs to your observability vendor. The identical secret in the header is dropped on the floor by all of them. Same transport, same trust, completely different retention.

And it lands closer to home than that. Your own shell keeps a copy of every command you run, so search it:

```
grep -oE 'https?://[^ ]*(access_token|sig|sessionid|api_?key)=[^& ]+' ~/.zsh_history | head
```

Anything that turns up is a credential sitting in a plaintext file on your disk, in a location no secret scanner is watching, and it will still be there in six months.

**Takeaways:** “Short-lived” is a claim, not a property: presigned URLs often carry a seven-day expiry, and seven days in a 30-day log is a real week of exposure. So I look up the actual number instead of trusting the word, and I check that the value is scoped to one object and one operation rather than to my whole account.

## Finding 2: one key, two programs, and no way to turn off half of it

Two programs that had nothing to do with each other were authenticating with the same password.

```
2026-08-27 21:27:59   api.anthropic.com   Bun/1.4.0             x-api-key   sk-ant-••••4AAA
2026-08-25 16:17:16   api.anthropic.com   Bun/1.4.0             x-api-key   sk-ant-••••4AAA
2026-08-12 18:07:19   api.anthropic.com   claude-code/2.1.221   x-api-key   sk-ant-••••4AAA
```

Look at the last column. `sk-ant-••••4AAA` is the same masked tail all three times, so it’s the same key. Now look at the client column: two entirely different programs sent it. `Bun/1.4.0` is a JavaScript runtime, filling the same role Node.js does, and something in my harness used it to call the Anthropic API directly. `claude-code` is the coding agent. Neither one knew the other existed.

Worth pausing on why I can see this at all. Passport shows a few characters of each secret, which is enough to prove two requests carried the same credential without the report itself becoming a plaintext list of my keys. That masked tail is doing real work here: it’s the only reason I can tell these three rows apart from three unrelated keys.

On its own, that is not an incident. Nothing was stolen and nothing was misused. It turns into a problem the first time I want to act on one of these programs, because the credential is the only thing I can act on.

Think about what the available actions actually are. Turning off access, replacing a value, narrowing what it can reach: every one of those is an operation on a key, not on a program. There is no button for “stop trusting this particular process.” So when one key serves two programs, the smallest move available to me covers both of them.

Say I decide the Bun process is suspect and the agent is fine. I want to cut off the first and leave the second running. I can’t. These are not two accounts with two passwords. They are two programs holding one password, and to the Anthropic API they are the same caller.

That leaves exactly one lever: replace the key everywhere, which is what rotating a credential means. Pulling it stops both programs at the same moment, and neither runs again until I have found and updated every copy on the machine. Fixing the program I am worried about requires breaking the program that was fine.

Responding to this costs an outage, which is why findings like this one sit. “We’ll rotate it after the release” is how a suspect key stays live for another quarter.

There’s an obvious way out of this. If the Anthropic API could see that one request came from my agent and the other from a Bun script, it could refuse one and keep serving the other, and I would have exactly the per-program control this section has been missing.

There is even a field that looks built for the job. Every HTTP request carries a [user agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent), a short string in which the client announces what software it is. It is what filled the `Client` column in my report, and it is the only reason I could tell those three rows apart at all.

Here is what that string is actually worth. The `-H` flag tells `curl` to set a header to whatever I want, so I’ll send two requests that each claim to be a different program:

```
curl -s "https://postman-echo.com/get" -H "user-agent: Bun/1.4.0" \
  | python3 -c "import json,sys; print('server saw:', json.load(sys.stdin)['headers']['user-agent'])"

curl -s "https://postman-echo.com/get" -H "user-agent: claude-code/2.1.221" \
  | python3 -c "import json,sys; print('server saw:', json.load(sys.stdin)['headers']['user-agent'])"
server saw: Bun/1.4.0
server saw: claude-code/2.1.221
```

Both of those were plain `curl`. Neither Bun nor the agent was anywhere near my machine at the time. The client picks the value, the server writes it down, and nothing anywhere verifies it. A user agent is a claim, not a credential, which makes it genuinely useful for statistics and worthless for access control. So the `Client` column in my report is a real clue about what happened, and it is not something any security decision could safely rest on.

That leaves the key itself, and the key carries even less. `sk-ant-••••4AAA` is one opaque string that says nothing about who is presenting it. To the API, whoever sends that string **is** the account. My agent, my Bun script, and anyone who copied the value out of my environment are the same caller, and there is no field in the request that could separate them. Possession is the entire identity, which is why there is nothing to turn off per program. That is not a flaw in Anthropic’s API. It is how API keys work everywhere.

Now compare the GitHub rows in the same report:

```
2026-08-27  api.github.com  claude-code/2.1.231  Bearer gho_••••08TL
2026-08-14  api.github.com  claude-code/2.1.223  Bearer gho_••••C9D5
```

Two different tails, so two different tokens, for what is nominally the same tool. The [GitHub OAuth device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) issues a credential per client instead of handing every client one shared key. Each can be turned off on its own, and killing one leaves the other working. That is precisely the control I wanted three paragraphs ago, and I got it by default rather than by being clever.

One last reason not to build policy on the client string. In my report, `claude-code` shows up under six distinct fingerprints: versions `2.1.221`, `2.1.223`, and `2.1.231`, each in both a `(cli)` and a bare variant, plus one `(sdk-cli)`. One tool, six identities, changing on its own release schedule. Any allowlist written against those strings would have been out of date within a month, and none of that drift required a single change to my code.

**Takeaways:** I stopped counting secrets and started counting consumers per secret. One key used by one program is a key I can rotate on a Tuesday afternoon. The same key used by four programs is an outage I have to schedule, which means in practice it never gets rotated at all. That number, consumers per credential, is the one worth watching, and it’s the number Passport’s holder-bound references are designed to hold at one.

## Finding 3: a credential I cannot attribute to anything

This row appeared twice, four weeks apart:

```
2026-09-09 14:52:44   mobile.events.data.microsoft.com   (empty)   apikey header   ••••
2026-08-13 20:09:32   mobile.events.data.microsoft.com   (empty)   apikey header   ••••
```

Note the `Client` column. It’s empty. Every other row in my report names the program that sent the request. These two name nothing.

Put plainly: something on this machine used a password to talk to Microsoft and didn’t sign its name. I know the call happened. I don’t know what made it.

I still don’t know what it was. Probably an editor or an SDK with telemetry compiled in. That’s the point: I have a credentialed outbound request, a destination, a timestamp, and no way to attribute it to a process from the network view alone. If that key were mine rather than a vendor’s, I’d have no idea which of the roughly 40 things running on this laptop to go turn off.

This is where runtime observation earns its keep and where it also shows its limit. Passport told me the request happened, which is 90% of the value, because I would otherwise have had zero knowledge of it. Attribution beyond the user agent needs process-level correlation, so pair the finding with `lsof` or `eslogger` on macOS if you need to close the loop.

**Takeaways:** An unattributable credential is worse than a known-bad one, because there is nothing to act on. I now treat an empty `Client` as its own severity class rather than a gap in the data, and I check it before I check anything else, since every other finding at least tells me where to go. It is also the clearest argument in my report for identity that a program cannot decline to provide.

## Finding 4: my agent is handing credentials to third parties

Seven of my 33 findings went to MCP endpoints:

```
mcp.vercel.com      claude-code/2.1.231 (cli)      Bearer token
mcp.vercel.com      claude-code/2.1.223 (cli)      Bearer token
mcp.atlassian.com   claude-code/2.1.231 (cli)      Bearer token
mcp.atlassian.com   claude-code/2.1.231 (cli)      sessionid query param
mcp.atlassian.com   claude-code/2.1.221 (sdk-cli)  Bearer token
mcp.atlassian.com   claude-code/2.1.221 (sdk-cli)  sessionid query param
```

A quick explanation if MCP is new to you. The [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) is a standard way to plug an agent into an outside service so it can read and act there. I connected two, Vercel and Atlassian, by pasting a token into a config file once, months ago. Since then my agent has decided on its own when to call them.

### The problem

An MCP server is a middleman. My agent doesn’t talk to Atlassian directly; it talks to `mcp.atlassian.com`, which talks to Atlassian on my behalf. That means the server receives a credential that can act as me, and two things follow from it.

The first is scope. Whatever that token can do, the server can do, for as long as the token lives. I never sat down and decided what those permissions should be. I pasted a token and moved on.

The second is subtler and has a name. If an MCP server takes the token my agent sent and forwards it unchanged to the real service, the real service sees a valid credential and has no way to know whether I authorized this particular action or whether the middleman replayed my token for its own reasons. The spec calls that **token passthrough** and explicitly forbids it, because it produces a [confused deputy](https://en.wikipedia.org/wiki/Confused_deputy_problem): a trusted component acting on instructions it should not have honored, using authority that isn’t its own.

There’s a third thing in those rows that should look familiar. Two of them put a `sessionid` in a query parameter, which is Finding 1 all over again, in traffic I didn’t write, to a service I don’t run.

### The solution

The specification is clear about whose job this is. MCP servers “MUST validate that tokens presented to them were specifically issued for their use.” A correctly built server checks that the token was minted for it, refuses anything else, and obtains its own downstream credential rather than replaying yours.

That’s the server’s obligation, not yours, which leaves you two things you can actually control. Give every MCP server its own credential, never one that’s shared with another consumer, so the Finding 2 revocation trap doesn’t apply. And treat each server like a service account you’re onboarding: what token does it get, what is that token scoped to, and what does it do with it downstream. The [MCP security best practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices) page is the checklist.

### Where Passport comes in

Two places, and they map to the two halves of the product.

The visibility half is why I know any of this. I had genuinely forgotten those servers were configured. No file on my machine would have told me my agent was sending bearer tokens to two third-party endpoints last month, because the traffic is the only place that fact exists. Seven findings, from a config I set up once and never revisited.

The architectural half is the more interesting one, because it makes the passthrough problem structurally impossible rather than asking a third party to be careful. If my agent holds a credential reference instead of a key, then what it can hand an MCP server is not a reusable secret. The reference is bound to its holder, so a middleman cannot replay it as me. The real credential resolves inside the Secure Access Proxy in my own network and never reaches the third party at all. There is nothing to pass through.

**Takeaways:** Every MCP server you configure is a third party you have given standing permission to act as you, and the count only goes up. I now audit them on the same schedule as service accounts, and I never point one at a credential another program is also using. The deeper fix is not trusting middlemen more carefully, it’s not giving them anything worth replaying.

## Set up Passport and read your own traffic

Everything above happened on one laptop, mine. Your hosts will be different, your clients will be different, and your worst finding is probably one I don’t have. That’s the reason to run this yourself instead of taking my numbers: I can tell you what to look for, but I can’t tell you what you’ll find.

So this is the one thing in this post I’d actually ask you to do. It costs a single command now and a week of ignoring it afterward, and at the end you have your own version of the report everything above is built on.

The Community Edition of the Passport CLI runs entirely locally, masks secret values, and does not send traffic anywhere. Install it from [npm](https://www.npmjs.com/package/@postman/postman-passport):

```
npm i -g @postman/postman-passport
passport --version
```

Then start the inspector. `passport lens` sits in the request path and captures calls from agents and developer tools without any change to those clients:

```
passport lens
```

Now go do your actual work for a week. Don’t instrument anything, don’t register credentials, don’t restructure your project around it. The entire value of this exercise is that you find out what your environment does when you aren’t watching it.

A few flags worth knowing:

```
passport lens --theme matrix     # passport, matrix, nord, or mocha
passport lens --ascii            # ASCII-only rendering for narrow terminals
passport lens --silent           # no terminal output
passport lens stop               # stop capturing
```

The dashboard groups findings by destination, client, and detection type, with a `Detected at` / `Destination` / `Client` / `Type` / `Masked secret` table underneath. **Export report** in the upper right gives you the artifact this post is built on. Full details are in the [Passport CLI documentation](https://docs.usepassport.ai/passport/reference/passport-cli-ce/overview/).

When you read your own report, work through it in this order:

1. **Filter the Type column for query parameters.** Anything ending in`query param` , plus`sig` ,`sessionid` , and`access_token` . That’s Finding 1, and it’s first because a credential in a URL is already written to disk in more systems than you can enumerate.
2. **Sort by masked tail and look for the same tail under different clients.** That’s Finding 2. Every group of two or more is a credential you cannot replace without planning an outage.
3. **Look for a blank client.** That’s Finding 3, and it’s the one to escalate rather than investigate, because there is nothing in the network view left to pull on.
4. **Count your third-party destinations, MCP or otherwise.** That’s Finding 4. Each one is something you gave standing permission to act as you, probably in a single sitting you no longer remember.

## Retrieval quality turned out to be a security variable

Watching the traffic and taking the secret out of the agent’s hands both make the existing calls safer. The last one is about not having to make some of those calls at all, and it’s the part I stumbled into. I started this project to measure something else entirely, and the Passport report handed me a variable I hadn’t planned to measure.

The harness compares agent runs with and without the [Context Graph API](https://www.postman.com/context-graph/), which answers cross-repository dependency questions from an indexed graph instead of making the model search for the answer. On the token and latency side the result was clear: prompt tokens fell 48% and cost fell 47%, in both models tested.

The number that matters *here* is a different one. Querying the graph first cut tool calls from 70 per prompt to 45, and agent steps from 32 to 20.

Cutting tool calls is not the same as cutting credential exposure, and it’s worth being careful about the difference. Plenty of tool calls are local file reads that touch no network at all. More importantly, the work the task actually requires doesn’t shrink. The same answer still needs the same authenticated endpoints, so the calls that do the job are irreducible.

What shrank here was the *searching*, and searching is where the credentialed subprocesses were coming from. `git`, `npm`, `curl`, MCP servers, and cloud CLIs all appear in my report because the agent reached for them while hunting for a dependency answer it couldn’t look up.

So the honest version of this is narrower than “fewer calls, fewer secrets.” Twenty-five fewer tool calls per prompt did not cut my credential count by a third, and I wouldn’t expect it to on a different task. What it removed was a class of calls that existed only because the agent didn’t know where to look, and in their place it added one authenticated destination: the graph itself. Whether that nets out in your favor depends on what the agent would otherwise have had to touch to answer the same question the slow way.

On this task it clearly did, and that’s the part I think generalizes: an agent that starts from a candidate list instead of searching several hundred repositories spawns fewer subprocesses and reaches fewer hosts to complete the same work. Retrieval quality is a security variable, not just a cost one, which I did not expect going in.

## Before you ship

**Don’t treat the report as an incident.** A finding means credential-bearing traffic was observed. It is not evidence of compromise or unauthorized disclosure. Read it as an inventory of your real credential graph, then triage by severity.

**Check the report itself for things you don’t want to publish.** Two of my destinations were a Postgres project reference and a private telemetry domain. Host names are not secrets, but they are infrastructure detail, and I redacted both before this post. The masked secret column is safe by design; the destination column is not necessarily.

**Scope by object, not by account, when a URL has to carry a credential.** Presigned URLs are fine when the signature covers one object and one method. They’re a liability when they carry account-level authority.

**Audit your MCP configuration on a schedule.** Finding 4 covers what to check per server. The part worth putting in a calendar is that the list grows every time someone connects a new one, and nobody notices a config that was added six months ago.

**Log the security dimension in your agent evals.** If you’re already capturing tokens, latency, and cost per run, add the destinations contacted, the authorization class used, and whether access was direct or delegated. A configuration that produces the same answer while touching fewer hosts is better along an axis most harnesses don’t measure.

## Resources

*Methodology: figures come from a Postman Passport export generated 2026-09-10 covering traffic from 2026-08-12 to 2026-09-09. Passport detects secrets in outbound API traffic and masks the values automatically. A finding indicates observed credential-bearing traffic and does not by itself establish disclosure or compromise. Destination host names have been redacted where they identify private infrastructure.*
