Agents need credentials to do real work. A browser agent signs in to a dashboard with a username and password. A coding agent in a sandbox calls a model provider with an API key. The usual way to get them there is to put the value somewhere the agent can reach: a line in the prompt, an argument in a tool call, an environment variable baked into an image.
That works, but the value spreads. It ends up in transcripts, logs, traces and your model provider's request history, and anything that can read those can read the secret. A browser agent has it worst: to type a password into a login form, the model has to hold the password.
Credential fill and secrets remove that step. You store a value once as a secret, and your browser sessions and sandboxes use it by name. The value is decrypted only where it's used, and it never reaches your code, your logs or the model.
One secret store, encrypted before it leaves your machine
Every secret is encrypted on your machine, by the SDK, the hx CLI or the dashboard,
before it's uploaded. The API receives an encrypted envelope bound to that one secret and stores it as
is. No endpoint returns a value, and the plaintext is decrypted only in the VM of the browser or
sandbox that uses it.
A secret holds a plain value, such as a password or an API key, a TOTP seed for two-factor codes, or a 1Password service account token. Secrets can be grouped into vaults, so a browser session or sandbox takes a whole set by one name.
- Allowed origins decide where it's typed. A secret can be filled into a page only after you list the sites it may be used on.
- Versioned. Storing a new value adds a version. Everything that uses the secret picks up the latest version unless you pin one, so rotating a password or key doesn't need a redeploy.
-
Permissioned. Using secrets needs an API key with
secrets.use, separate fromsecrets.managefor creating and rotating them. In the dashboard, only team owners and admins can manage them.
Fill secrets into login forms
A login form doesn't read environment variables. Something has to type the password into the page. Credential fill makes that the platform's job.
Store each value you want typed as its own secret, with the origins it may be used on, and group a site's values in a vault. Attach the vault when you create a browser session, then ask for a fill once the session is on the login page:
import os
from playwright.sync_api import sync_playwright
from hyperbrowser import Hyperbrowser
client = Hyperbrowser()
origins = ["https://github.com"]
client.secrets.create({
"name": "github-username",
"value": os.environ["GITHUB_USERNAME"],
"allowed_origins": origins,
})
client.secrets.create({
"name": "github-password",
"value": os.environ["GITHUB_PASSWORD"],
"allowed_origins": origins,
})
client.vaults.create({
"name": "github-login",
"entries": {
"USERNAME": {"name": "github-username"},
"PASSWORD": {"name": "github-password"},
},
})
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]")
A fill names a vault and, for each input, the entry to type and the input's CSS selector, so one call
fills the whole form. A single secret works without a vault: attach it by name and fill it with
"credential": "github-password". One secret can go into several inputs at once, such as a
password and its confirmation field. The platform decrypts each secret inside the browser's VM and
types it in. The response reports, for each input, the outcome and the secret version it received, and
never contains the value:
-
filled: the input holds exactly the value. -
not_foundorambiguous: no input, or more than one visible input, matched the selector. -
not_editable: the input is disabled, read-only, hidden or not a text field. -
not_accepted: the page didn't keep the value, for example because of a length limit.
Values are typed the way the element expects. A value read from a file usually ends with a newline,
which a single-line <input> can't hold, so it's typed without it; a
<textarea> keeps its line breaks.
Fill only types. It never submits, so you decide when to click "Sign in", and a fill is always safe to
retry. With several tabs open, pass page_url to pick the tab whose URL starts with it,
such as https://github.com/login.
Two-factor codes
Many logins ask for a one-time code after the password. Store the seed the site shows when you set up
an authenticator app as a totp secret, and a fill types the code that is current at that
moment. The seed stays inside the VM and never reaches the browser, your code or the model.
Add the code to the login's vault before you create the session, then fill it when the site asks:
client.secrets.create({
"name": "github-otp",
"type": "totp",
"value": os.environ["GITHUB_TOTP_SEED"], # base32, or an otpauth://totp/ URI
"allowed_origins": ["https://github.com"],
})
client.vaults.update("github-login", {"entries": {"OTP": {"name": "github-otp"}}})
# ...create the session and sign in as above, then on the two-factor page:
page.wait_for_selector("#app_totp")
client.sessions.credentials.fill(session.id, {
"vault": "github-login",
"fields": [{"field": "OTP", "selector": "#app_totp"}],
})The seed is checked on your machine before upload, since the platform can't read it afterwards. The fill result says when the code expires, so a fill that lands close to the end of a code's window can be repeated.
Fill from 1Password
If your logins already live in 1Password, you don't have to copy them. Store a 1Password service
account token as an op_token secret and attach it to the session. A fill then names the
1Password vault, the origins its values may be typed on, and the 1Password field for each input:
client.secrets.create({
"name": "op-token",
"type": "op_token",
"value": os.environ["OP_SERVICE_ACCOUNT_TOKEN"],
})
session = client.sessions.create({"credentials": [{"one_password": "op-token"}]})
# ...open the login page in the session, then:
client.sessions.credentials.fill(session.id, {
"one_password": {
"token": "op-token",
"vault": "Engineering",
"allowed_origins": ["https://github.com"],
},
"fields": [
{"selector": "#login_field", "ref": {"item": "GitHub", "field": "username"}},
{"selector": "#password", "ref": {"item": "GitHub", "field": "password"}},
],
})
The browser's VM signs in to 1Password with the token and reads the fields. The token never reaches
the browser, and neither the token nor the values reach the API. Nothing stored says where a 1Password
value belongs, so each fill names its allowed origins, and the page is checked against them like any
other fill. A field can also be a one-time code, such as
one-time password?attribute=totp.
Typed only where you said
Before typing anything, the platform checks the origin the browser reports for the target page against
the secret's allowed origins: https://github.com, or https://*.example.com
for a site's subdomains. On a look-alike domain, a redirect to an unexpected site or an unexpected
iframe, the fill is refused and the form stays empty. When a fill types several values, the page must
be allowed by every one of them.
That check runs in the platform, not in the agent. A model can be steered to the wrong site, but it can't make the platform fill a secret there.
- Attached up front. A session can only fill secrets attached when it was created. A vault attaches the secrets its entries hold at that moment.
- No saved passwords. Sessions with attached secrets run with Chrome's password manager turned off.
- Kept out of replays. Inputs a fill writes to are excluded from session replays.
- Tracked through snapshots. Snapshots of a session that received a secret are marked as containing secrets, and restoring one needs the same access as using them.
- Recorded. Every fill, and every refused attempt, is written to the session's event log with the secret and version each input received.
Let the agent sign in
The Python and Node SDKs include a ready-made fill_credential tool for OpenAI, Anthropic
and Gemini. Add it to your agent's tools, and the agent can sign in on its own:
import json
from hyperbrowser.tools import CredentialFillTool
tools = [CredentialFillTool.openai_tool_definition] # plus your click and navigate tools
for call in message.tool_calls:
if call.function.name == "fill_credential":
output = CredentialFillTool.runnable(
client, session.id, json.loads(call.function.arguments)
)Your application binds the session. The model only chooses which secret to use and which inputs to type into, by name and selector. Failures come back as structured results the model can act on: if several open tabs could hold the login form, it gets the list of tabs and retries with the right one.
In an end-to-end run, an OpenAI model and a Gemini model each got the tool, the names of the stored login and the login form. Each one chose the inputs, filled them and signed in. A search of everything the model sent and received found the password zero times.
Secrets for sandbox processes
The same secrets cover values your sandboxes need, such as API keys. A secret reaches a sandbox process as an environment variable. Pass it by name when you start the process, and the platform sets it in that process's environment, inside the sandbox's VM:
hx secrets create provider-key --from-file ./provider-key.txt
hx vm exec <sandbox-id> --secret API_KEY=provider-key -- node app.jsThe same flow from the Python SDK:
client.secrets.create({
"name": "provider-key",
"value": open("provider-key.txt", "rb").read(),
})
sandbox = client.sandboxes.create({"image_name": "node"})
result = sandbox.exec("node app.js", secrets={"API_KEY": "provider-key"})
Vaults and 1Password work here too. A vault sets each of its entries as a variable, and
one_password reads variables straight from a 1Password vault when the process starts:
client.vaults.create({"name": "app-env", "entries": {"API_KEY": {"name": "provider-key"}}})
result = sandbox.exec("node app.js", vault="app-env")
result = sandbox.exec("node app.js", one_password={
"token": "op-token",
"vault": "Engineering",
"fields": {"DB_PASS": {"item": "Postgres", "section": "prod", "field": "password"}},
})- One process at a time. Only the process you pass the secret to receives it. Other processes don't inherit it.
-
Exact bytes. Environment variables get the value exactly as stored. A
totpsecret gives the code current when the process starts. - Out of your shell history. The CLI reads values from a file or stdin, never from arguments.
- Startup commands. Pass secrets, a vault or 1Password fields when you create a sandbox, and the image's startup command starts with its keys in place.
- Tracked through snapshots. A sandbox that received a secret, and every snapshot of it, is marked as containing secrets, just like browser sessions.
Know what stays readable
Credential fill and secrets keep values out of your code, prompts, transcripts and the API. Some things they can't change:
- A filled field belongs to the page. The site's own scripts, browser extensions and your own CDP connection to the session can read it.
- Screens show what's on them. Password fields are masked by the page, but a filled username is visible in video recordings and the live view.
- A process can read its environment. Inside a sandbox, anything running as the same user, or as root, can read the environment of a process that received a secret.
- A 1Password token reads what it can reach. Any field the service account can see can be filled or set as a variable, so give it only the vaults your agents need.
- Deleting isn't revoking. Deleting a secret doesn't take back a value that was already delivered. If a value may have leaked, rotate it where it was issued.
Get started
Credential fill, vaults, two-factor codes and 1Password are available in the Node.js SDK 1.3.0, the
Python SDK 1.13.0, the hx CLI and the REST API. Both features are enabled for teams on
request, so please contact support to enable them for your team.
-
Fill a login: create secrets with allowed origins (
allowed_origins, orhx secrets update <name> --allowed-origin <origin>) and group them withclient.vaults.createorhx vaults. Attach the vault or secret withcredentialswhen you create a session, and fill it withclient.sessions.credentials.fillorhx browser fill. -
Two-factor codes: create a secret with type
totpand add it to the login's vault. -
1Password: store a service account token as an
op_tokensecret. Attach it to a session with{"one_password": "<name>"}, or passone_passwordto a sandbox. -
Agents: add
CredentialFillToolto your OpenAI, Anthropic or Gemini tools. -
Sandbox secrets: create one with
client.secrets.createorhx secrets create, then pass it, or a vault, tosandbox.exec,sandbox.processes.startorhx vm exec --secret.
Both features are in public preview, and we're actively looking for feedback. Tell us which sites and login flows you want your agents to handle, what's missing for your use case, and anything that surprised you. Reach us on X, on Discord, or through support on hyperbrowser.ai.
