# The Model Should Never See Your Password

> Source: <https://pub.towardsai.net/the-model-should-never-see-your-password-cbf49d49506f?source=rss----98111c9905da---4>
> Published: 2026-11-15 15:01:01+00:00

Every demo of a browser agent has a moment where the thing signs in somewhere. Type the email, type the password, click through the two-factor screen, land on the dashboard.

It looks like magic. It also means the model held your password in its context to do it.

That password traveled through your prompt or a tool call argument, into the transcript, into your logs and traces, and into the request history your model provider keeps. A browser agent has it worst of all workflows: to type a password into a login form, something in the stack has to know the password. Today, that something is usually the LLM.

Hyperbrowser shipped a feature called credential fill in October 2026 that removes the model from that path ([launch post](https://www.hyperbrowser.ai/blog/credential-fill-and-secrets), [docs](https://hyperbrowser.ai/docs/sessions/credentials)). I spent an evening with the documentation, and this article is my read on how it works and why several of its choices are smarter than they first look.

**The 30-second version:** store logins and 2FA seeds as encrypted secrets, attach them to a browser session by name, and let the platform type them into the login form. Values are encrypted on your machine, decrypted only inside the session’s browser VM, and never appear in your code, your logs, or the model’s context.

The usual pattern hands the credential to the agent through one of these channels:

None of these are exotic failure modes. This is the system working as designed.

The value spreads because the architecture asks the model to be the courier.

**Store.** You create a secret through the SDK, the hx CLI, or the dashboard. The SDK encrypts the value on your machine before it uploads anything. The API receives an encrypted envelope bound to that one secret and stores it as-is.

A secret can be a plain value (a password or API key), a TOTP seed for two-factor codes, or a 1Password service account token. You also give it a list of allowed origins, the only sites where it may ever be typed.

**Attach.** When you create a browser session, you attach the vault or individual secrets by name. A session can only fill what it was created with, up to 20 credentials. Attaching anything forces a Firecracker browser and turns off Chrome’s password manager.

**Fill.** Once the session is on the login page, you ask for a fill. Here is the Python version with Playwright, condensed from the docs:

```
session = client.sessions.create({"credentials": [{"vault": "github-login"}]})with sync_playwright() as p:    page = p.chromium.connect_over_cdp(session.ws_endpoint).contexts[0].pages[0]    page.goto("https://github.com/login")    result = client.sessions.credentials.fill(session.id, {        "vault": "github-login",        "fields": [            {"field": "USERNAME", "selector": "#login_field"},            {"field": "PASSWORD", "selector": "#password"},        ],    })    if all(field.outcome == "filled" for field in result.fields):        page.click("input[type=submit]")
```

One call fills the whole form. Each field names a vault entry and a CSS selector. The platform decrypts each secret inside the browser’s VM and types it in.

The response reports, per input, an outcome and the secret version it received, and it never contains the value.

The outcome taxonomy is worth reading closely, because it tells you the designers expected the page to misbehave:

The features are less interesting than the reasoning behind them, so here are the five calls I’d put in front of anyone thinking about agent security.

Your code, or the model, decides when to click “Sign in.” The restraint is the load-bearing choice, because a fill that submits can be triggered at the wrong moment, on the wrong page, with no way back. A fill that only types is idempotent, safe to retry, and leaves the decision point with the human or the agent.

The docs state the timeout ceiling too: a fill takes up to about 18 seconds, so your HTTP client should allow at least 30.

Before anything is typed, the origin the browser reports for the target page is checked against the allowed origins of every secret in the fill. On any other origin, the fill fails with CREDENTIAL_ORIGIN_NOT_ALLOWED and nothing is typed.

The rules are strict: 1 to 20 origins in scheme://host[:port] form, no paths, wildcard subdomains that match subdomains but not the apex domain, and http:// accepted only for localhost.

Putting the policy on the secret itself means a compromised request cannot widen where a value may go.

The one exception proves the rule: for 1Password fills, origins are passed per request, because nothing stored in Hyperbrowser says where a 1Password value belongs. Which leads to my favorite decision in the whole feature.

Both SDKs ship a fill_credential tool definition for OpenAI, Anthropic, and Gemini. The model chooses a secret or vault by name, picks selectors, and never sees values.

But the tool offers no 1Password support, and the docs explain why in one sentence: a 1Password fill names its allowed origins in the request, so a model choosing them would be choosing where your values may be typed.

*Most teams would have shipped the symmetric API and called it flexible. This one wrote down why it didn’t.*

Beyond the per-field outcomes, the error codes cover the realistic mess: several tabs could be the target (CREDENTIAL_PAGE_AMBIGUOUS, with candidate details so you can pick one), a login form lives in an iframe (frameUrl handles it, and the iframe's own origin must be allowed), a page restored from a snapshot has Chrome's password manager on, a TOTP seed is malformed.

Nothing is typed on any of these failures. A fill that fails loudly and leaves the page untouched is worth more than one that half-succeeds.

This is the section I respect most.

Once a value is in the page, the site’s scripts, browser extensions, and your own CDP connection to the session can read the field. Video recordings and the live view show what’s on screen: password inputs are masked by the page, but a filled username or one-time code is visible.

The feature is scoped honestly. It takes the secret out of your code, your logs, and the model. It does not claim to protect the value after it lands in a page you asked it to log into.

Two extensions round out the login story.

For two-factor logins, you store the authenticator’s base32 seed as a secret of type totp. Each fill types the code that is current at that moment, and the result includes an expiresAt so you know when to fill again before submitting. The seed never leaves the VM.

For teams whose logins already live in 1Password, a session can fill fields read directly from a 1Password vault using a service account token. The values are read at fill time and never stored by Hyperbrowser. Each field is addressed with a 1Password reference like op://Dev Vault/scrapingcourse login/username, and one-time codes use an ?attribute=otp suffix on the field reference.

Put the password in the prompt and the model sees it, the logs keep it, and rotating it means re-prompting or redeploying.

Bake it into an environment variable and the model may not see it directly, but any process on the machine can read it, rotation means rebuilding the image, and nothing constrains where the value gets typed afterward.

Credential fill changes all three at once. The model never sees the value, because it refers to secrets by name. The value never enters your logs or traces, because it is decrypted only inside the browser’s VM. Rotation is an API call: secrets are versioned, and everything using a secret picks up the latest version unless you pin one. And the allowed-origins list is the only place the value can ever be typed.

**Does the LLM ever see the password?**

No. The model refers to secrets by name through the fill_credential tool. The runner returns fill outcomes as JSON, and no endpoint returns a value.

**What if my selector matches two inputs on the page?**

The fill returns ambiguous for that field and types nothing. Only inputs a person could see count, so hidden copies of a form, such as a mobile layout or a honeypot field, are skipped.

**Can I use it with my own Playwright or Puppeteer scripts?**

Yes. You connect to the session over CDP and drive the browser as usual. Credential fill is one API call on top; navigation, clicking, and submitting stay in your code.

**How do I rotate a password?**

Store a new version of the secret. Fills use the latest version unless you pin one, so nothing that references the secret by name has to change.

**What does it cost to get started?**

Credential fill is in public preview and enabled for teams on request. Contact Hyperbrowser support to enable it, and note that a restricted API key needs secrets.manage to create secrets and secrets.use to fill them.

I read these docs the way I’d read a codebase I want to work in, and the thing that stands out is restraint. Every place the design could have been more flexible or more impressive in a demo, it chose the version that leaks less.

Declining to give the model a capability is a harder call than adding one. This team made that call in at least three places.

Agent security is the layer of this stack I find most interesting, and this is the kind of problem I want to spend my time on. If you’re building it, I’d love to talk.

*If this was useful, leave a few claps so other agent builders find it. And tell me in the comments where you think credential fill goes too far, or not far enough. I read every one.*

[The Model Should Never See Your Password](https://pub.towardsai.net/the-model-should-never-see-your-password-cbf49d49506f) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.
