> ## Documentation Index
> Fetch the complete documentation index at: https://hyperbrowser.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Credential fill

> Type stored logins and one-time codes into a session's login form, without your code or an LLM seeing the values.

Credential fill types stored [secrets](/docs/sandboxes/secrets), such as a username, a password or a one-time code, into a login form in a browser session. Values are encrypted on your machine before they're uploaded, and the API never returns them. When your session reaches a login page, you ask Hyperbrowser to fill the form: the values are decrypted on the host running the session's browser and typed into the inputs you choose. Your code, your logs and any LLM driving the browser refer to the secrets by name only.

<Note>
  Credential fill is in public preview and is enabled for teams on request. Please contact us at [info@hyperbrowser.ai](mailto:info@hyperbrowser.ai) or on our support chat to enable it for your team. A restricted API key needs `secrets.manage` to create secrets and vaults, `secrets.use` to attach them to a session, and `secrets.use` with `browser.sessions.update` to fill them. It's available in the Node SDK from 1.3.0, the Python SDK from 1.13.0, and the [CLI](/docs/sandboxes/cli).
</Note>

## Store a login

A login is a few ordinary secrets, one per input, each with the **allowed origins** it may be typed into. A secret can't be filled until it has at least one. To fill a one-time code, store the authenticator seed as a secret of type `totp`: each fill types the code that's current at that moment.

Group the secrets in a [vault](/docs/sandboxes/vaults) so you can attach and fill them by one name. The vault's entry names, such as `USERNAME` and `PASSWORD`, are the field names you fill by.

<CodeGroup>
  ```typescript Node.js theme={null}
  import { Hyperbrowser } from "@hyperbrowser/sdk";

  const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY });
  const allowedOrigins = ["https://github.com"];

  await client.secrets.create({ name: "github-username", value: process.env.GITHUB_USERNAME!, allowedOrigins });
  await client.secrets.create({ name: "github-password", value: process.env.GITHUB_PASSWORD!, allowedOrigins });
  await client.secrets.create({
    name: "github-otp",
    type: "totp",
    value: process.env.GITHUB_TOTP_SEED!,
    allowedOrigins,
  });

  await client.vaults.create({
    name: "github-login",
    entries: {
      USERNAME: { name: "github-username" },
      PASSWORD: { name: "github-password" },
      OTP: { name: "github-otp" },
    },
  });
  ```

  ```python Python theme={null}
  import os
  from hyperbrowser import Hyperbrowser

  client = Hyperbrowser(api_key=os.environ["HYPERBROWSER_API_KEY"])
  allowed_origins = ["https://github.com"]

  client.secrets.create({"name": "github-username", "value": os.environ["GITHUB_USERNAME"], "allowed_origins": allowed_origins})
  client.secrets.create({"name": "github-password", "value": os.environ["GITHUB_PASSWORD"], "allowed_origins": allowed_origins})
  client.secrets.create({
      "name": "github-otp",
      "type": "totp",
      "value": os.environ["GITHUB_TOTP_SEED"],
      "allowed_origins": allowed_origins,
  })

  client.vaults.create({
      "name": "github-login",
      "entries": {
          "USERNAME": {"name": "github-username"},
          "PASSWORD": {"name": "github-password"},
          "OTP": {"name": "github-otp"},
      },
  })
  ```

  ```bash CLI theme={null}
  hx secrets create github-username --allowed-origin https://github.com --from-file ./username.txt
  hx secrets create github-password --allowed-origin https://github.com --from-file ./password.txt
  hx secrets create github-otp --type totp --allowed-origin https://github.com --from-file ./otp-seed.txt

  hx vaults create github-login \
    --entry USERNAME=github-username --entry PASSWORD=github-password --entry OTP=github-otp
  ```
</CodeGroup>

An allowed origin is `scheme://host[:port]`, with no path. `https://*.example.com` matches any subdomain of `example.com` but not `example.com` itself, and `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. A secret takes 1–20 origins. Allowed origins only limit fills: the same secret can still be passed to a [sandbox](/docs/sandboxes/secrets), where a process that names it, or a vault holding it, receives the value, or a `totp` secret's current code.

A TOTP value is the authenticator's base32 seed, or an `otpauth://totp/...` URI when the site uses a non-default algorithm, digit count or period. Spaces, hyphens and lowercase letters in a seed are fine. The SDKs and CLI check the value before uploading it, because the API can't see it: an invalid seed only fails when it's used.

To change a secret's origins later, see [Manage logins](#manage-logins).

## Fill a login form

Attach the vault, or single secrets, when you create the session; a session can only fill what it was created with. Then navigate to the login page and fill it. These examples use Playwright: install `playwright-core` (Node.js) or `playwright` (Python).

<CodeGroup>
  ```typescript Node.js theme={null}
  import { Hyperbrowser } from "@hyperbrowser/sdk";
  import { chromium } from "playwright-core";

  const client = new Hyperbrowser({ apiKey: process.env.HYPERBROWSER_API_KEY });

  const session = await client.sessions.create({ credentials: [{ vault: "github-login" }] });
  try {
    const browser = await chromium.connectOverCDP(session.wsEndpoint);
    const page = browser.contexts()[0].pages()[0];
    await page.goto("https://github.com/login");

    const result = await client.sessions.credentials.fill(session.id, {
      vault: "github-login",
      fields: [
        { field: "USERNAME", selector: "#login_field" },
        { field: "PASSWORD", selector: "#password" },
      ],
    });
    console.log(result.origin, result.fields);

    // Fill only types. Submitting is up to you.
    if (result.fields.every((field) => field.outcome === "filled")) {
      await page.click("input[type=submit]");
    }
  } finally {
    await client.sessions.stop(session.id);
  }
  ```

  ```python Python theme={null}
  import os
  from hyperbrowser import Hyperbrowser
  from playwright.sync_api import sync_playwright

  client = Hyperbrowser(api_key=os.environ["HYPERBROWSER_API_KEY"])

  session = client.sessions.create({"credentials": [{"vault": "github-login"}]})
  try:
      with sync_playwright() as p:
          browser = p.chromium.connect_over_cdp(session.ws_endpoint)
          page = browser.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"},
              ],
          })
          print(result.origin, result.fields)

          # Fill only types. Submitting is up to you.
          if all(field.outcome == "filled" for field in result.fields):
              page.click("input[type=submit]")
  finally:
      client.sessions.stop(session.id)
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.hyperbrowser.ai/api/session \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"credentials": [{"vault": "github-login"}]}'

  # After navigating the session to https://github.com/login:
  curl -X POST https://api.hyperbrowser.ai/api/session/SESSION_ID/credentials/fill \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "vault": "github-login",
      "fields": [
        {"field": "USERNAME", "selector": "#login_field"},
        {"field": "PASSWORD", "selector": "#password"}
      ]
    }'
  ```

  ```bash CLI theme={null}
  hx browser create --vault github-login
  hx browser fill <session-id> --vault github-login \
    --field USERNAME=#login_field --field PASSWORD=#password
  ```
</CodeGroup>

Each field names a vault entry and the CSS selector of its input. To fill one secret instead, pass `credential` with the secret's name or ID and give each field only a `selector`:

<CodeGroup>
  ```typescript Node.js theme={null}
  await client.sessions.credentials.fill(session.id, {
    credential: "github-password",
    fields: [{ selector: "#password" }],
  });
  ```

  ```python Python theme={null}
  client.sessions.credentials.fill(session.id, {
      "credential": "github-password",
      "fields": [{"selector": "#password"}],
  })
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.hyperbrowser.ai/api/session/SESSION_ID/credentials/fill \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"credential": "github-password", "fields": [{"selector": "#password"}]}'
  ```

  ```bash CLI theme={null}
  hx browser fill <session-id> --credential github-password --selector '#password'
  ```
</CodeGroup>

The session's `credentials` list takes up to 20 items: a secret's name, `{ secret: name }`, or `{ vault: name }`. A vault attaches the secrets its entries point at when the session is created. Attaching anything forces a Firecracker browser and turns off Chrome's password manager. [1Password vaults](/docs/sandboxes/vaults#1password-vaults) can't be attached to browser sessions.

### One-time codes

Logins with two-step verification usually ask for the code on the next screen. Submit the first screen, then fill the `totp` entry there:

<CodeGroup>
  ```typescript Node.js theme={null}
  await page.waitForSelector("#app_totp");
  const otp = await client.sessions.credentials.fill(session.id, {
    vault: "github-login",
    fields: [{ field: "OTP", selector: "#app_totp" }],
  });
  console.log(otp.fields[0].expiresAt);
  ```

  ```python Python theme={null}
  page.wait_for_selector("#app_totp")
  otp = client.sessions.credentials.fill(session.id, {
      "vault": "github-login",
      "fields": [{"field": "OTP", "selector": "#app_totp"}],
  })
  print(otp.fields[0].expires_at)
  ```
</CodeGroup>

The field's `expiresAt` says when the typed code stops being current. If you're about to submit after that, fill it again.

## Results

The response reports an outcome per field, in request order, and never contains values:

```json theme={null}
{
  "pageId": "6C1F0E5A9B2D47E8A1F3C2B4D5E6F708",
  "origin": "https://github.com",
  "fields": [
    { "field": "USERNAME", "selector": "#login_field", "outcome": "filled", "secretId": "…", "version": 1 },
    { "field": "PASSWORD", "selector": "#password", "outcome": "filled", "secretId": "…", "version": 3 }
  ]
}
```

| Outcome | Meaning |
| - | - |
| `filled` | The input holds exactly the stored value. |
| `not_found` | No input matched the selector. |
| `ambiguous` | More than one visible input matched the selector. |
| `not_editable` | The input is disabled, read-only, hidden, or not a text input. |
| `not_accepted` | The page didn't keep the value, for example because of a `maxlength`. |

Each field also reports the `secretId` and `version` typed, and `expiresAt` for a TOTP code. A single-secret fill also has a top-level `version`. Only inputs a person could see count toward a selector, so a hidden copy of a form, such as a mobile layout or a honeypot field, is skipped. A fill takes 1–16 fields, each selector at most once. It never submits the form and is safe to retry. For logins split over several screens, fill the fields on the current screen, submit, and fill the rest on the next one.

## Choose the page and frame

Without a page ID, the fill picks a tab in steps. It keeps the tabs whose URL starts with `pageUrl`, if you pass one. If a single tab is left, it's used, even if it isn't on an allowed origin, in which case the fill fails with `CREDENTIAL_ORIGIN_NOT_ALLOWED`. Otherwise it prefers the tabs on an allowed origin of every secret being filled, when there are any, and then the one visible tab among them. When several tabs could still be the target, the fill fails with `CREDENTIAL_PAGE_AMBIGUOUS`, and the error's details list each candidate's `pageId`, `url` and `title`. Pass `pageId` (`page_id` in Python), the tab's CDP target ID, or a longer `pageUrl` (`page_url`) to choose one. You can't pass both.

Under Playwright every tab reports as visible, so pass a page when more than one open tab is on an allowed origin:

<CodeGroup>
  ```typescript Node.js theme={null}
  const cdp = await page.context().newCDPSession(page);
  const { targetInfo } = await cdp.send("Target.getTargetInfo");

  await client.sessions.credentials.fill(session.id, {
    vault: "github-login",
    pageId: targetInfo.targetId,
    fields: [{ field: "PASSWORD", selector: "#password" }],
  });
  ```

  ```python Python theme={null}
  client.sessions.credentials.fill(session.id, {
      "vault": "github-login",
      "page_url": "https://github.com/login",
      "fields": [{"field": "PASSWORD", "selector": "#password"}],
  })
  ```
</CodeGroup>

For a login form inside an iframe, pass `frameUrl` (`frame_url` in Python; `frame: { "url": … }` in the API) with the start of the iframe's URL, such as `https://accounts.example.com/login`. The iframe's own origin must be allowed.

## Use with an LLM

Both SDKs include a `fill_credential` tool definition for OpenAI, Anthropic and Gemini, and a runner that executes the model's call in the session you bind. The model chooses a secret (`credential`) or a vault, the field and selector for each input, and optionally `page_id`, `page_url` or `frame_url`; it never sees the values. The runner returns JSON text for the model: the fill result, or `{"error": {"code", "message", "retryable", "details"}}` so the model can react, for example by retrying with a `page_id` from `details.pages`.

Give the model your other browser tools as well, since it has to submit the form itself.

<CodeGroup>
  ```python Python (OpenAI) theme={null}
  import json
  from openai import OpenAI
  from hyperbrowser.tools import CredentialFillTool

  openai_client = OpenAI()
  tools = [CredentialFillTool.openai_tool_definition]  # plus your browser tools
  messages = [{"role": "user", "content": "Sign in to GitHub with the vault named github-login."}]

  while True:
      response = openai_client.chat.completions.create(model="gpt-5", messages=messages, tools=tools)
      message = response.choices[0].message
      messages.append(message)
      if not message.tool_calls:
          break
      for call in message.tool_calls:
          if call.function.name == "fill_credential":
              output = CredentialFillTool.runnable(client, session.id, json.loads(call.function.arguments))
          else:
              output = run_browser_tool(call)  # your click and navigate tools
          messages.append({"role": "tool", "tool_call_id": call.id, "content": output})
  ```

  ```typescript Node.js (Gemini) theme={null}
  import { GoogleGenAI } from "@google/genai";
  import { CredentialFillTool } from "@hyperbrowser/sdk/tools";

  const ai = new GoogleGenAI({});
  const config = { tools: [{ functionDeclarations: [CredentialFillTool.geminiToolDefinition] }] };
  const contents: any[] = [{ role: "user", parts: [{ text: "Sign in to GitHub with the vault named github-login." }] }];

  while (true) {
    const response = await ai.models.generateContent({ model: "gemini-3.5-flash", contents, config });
    contents.push(response.candidates![0].content);
    if (!response.functionCalls?.length) break;
    const parts = [];
    for (const call of response.functionCalls) {
      const output = await CredentialFillTool.runnable(client, session.id, call.args as any);
      parts.push({ functionResponse: { id: call.id, name: call.name, response: { result: JSON.parse(output) } } });
    }
    contents.push({ role: "user", parts });
  }
  ```
</CodeGroup>

The definitions are `openai_tool_definition`, `anthropic_tool_definition` and `gemini_tool_definition` in Python, and `openaiToolDefinition`, `anthropicToolDefinition` and `geminiToolDefinition` in Node.js. Python also has `CredentialFillTool.async_runnable` for `AsyncHyperbrowser`.

## Manage logins

Login secrets are ordinary secrets, so you manage them with the secrets methods; see [Secrets](/docs/sandboxes/secrets#rotate-and-delete) to rotate values and [Vaults](/docs/sandboxes/vaults) to change entries. Each method takes a name or ID.

<CodeGroup>
  ```typescript Node.js theme={null}
  // Replace the allowed origins, or clear them with null.
  await client.secrets.setAllowedOrigins("github-password", ["https://github.com", "https://gist.github.com"]);

  // Store a new value; fills use the latest version.
  await client.secrets.addVersion("github-password", newPassword);
  ```

  ```python Python theme={null}
  # Replace the allowed origins, or clear them with None.
  client.secrets.set_allowed_origins("github-password", ["https://github.com", "https://gist.github.com"])

  # Store a new value; fills use the latest version.
  client.secrets.add_version("github-password", new_password)
  ```

  ```bash cURL theme={null}
  curl -X PATCH https://api.hyperbrowser.ai/api/secrets/SECRET_ID \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"metadata": {"browser": {"allowedOrigins": ["https://github.com", "https://gist.github.com"]}}}'
  ```

  ```bash CLI theme={null}
  hx secrets update github-password --allowed-origin https://github.com --allowed-origin https://gist.github.com
  hx secrets set github-password --from-file ./password.txt
  ```
</CodeGroup>

The API addresses secrets by ID; `{"metadata": {"browser": null}}` clears the origins, after which the secret can't be filled.

## How credentials are protected

* **Encrypted before upload.** Values are encrypted on your machine and decrypted only by the host running the session that fills them. No endpoint returns a value, and fill results contain outcomes only.
* **Typed only on allowed origins.** Before anything is typed, the origin the browser reports for the target page or frame is checked against the allowed origins of every secret being filled. On any other origin the fill fails with `CREDENTIAL_ORIGIN_NOT_ALLOWED` and nothing is typed. Allowed origins don't apply to sandboxes, which receive any secret a process names; keep a login's secrets out of vaults used with sandboxes if only browsers should have them.
* **Attached up front.** A session can only fill secrets attached when it was created. Attaching forces a Firecracker browser and turns off Chrome's password manager.
* **Never submitted for you.** Fill only types. Your code or model decides whether to submit.
* **Kept out of replays.** Inputs a fill writes to are excluded from session replays.
* **Tracked through snapshots.** A session that received a credential, and its snapshots, are marked as containing secrets. Restoring such a snapshot needs the same access as using credentials.
* **Recorded.** Every fill and every refused attempt is written to the session's event log as `credential_filled` or `credential_fill_refused`, with the secret IDs and versions, never values. A refusal's `metadata.reason` is the error code.

```bash cURL theme={null}
curl "https://api.hyperbrowser.ai/api/session/SESSION_ID/event-logs?types=credential_filled&types=credential_fill_refused" \
  -H "x-api-key: YOUR_API_KEY"
```

Some things stay readable. Once a value is in a 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.

## Errors

Errors have a `code`, a `message`, and sometimes `retryable` and `details`.

| Code | Status | Meaning |
| - | - | - |
| `CREDENTIAL_ORIGIN_NOT_ALLOWED` | 403 | The page's origin isn't allowed; `details.origin` names it. Nothing was typed. |
| `CREDENTIAL_NOT_ATTACHED` | 403 | The secret wasn't attached when the session was created, or a vault entry now points at a different secret. |
| `CREDENTIAL_NOT_READY` | 409 | The secret has no allowed origins, or no value. |
| `CREDENTIAL_PAGE_AMBIGUOUS` | 409 | Several tabs could be the target; pick one from `details.pages`. |
| `CREDENTIAL_FRAME_AMBIGUOUS` | 409 | Several frames match the frame URL; make it longer. |
| `CREDENTIAL_PAGE_NOT_FOUND`, `CREDENTIAL_FRAME_NOT_FOUND` | 404 | No such tab, or no frame with that URL. |
| `SECRET_NOT_FOUND`, `VAULT_NOT_FOUND`, `VAULT_ENTRY_NOT_FOUND` | 404 | No secret, vault or vault entry with that name. |
| `SECRET_TYPE_MISMATCH` | 409 | The secret can't be filled, such as a 1Password token (`op_token`). |
| `VAULT_SOURCE_INVALID` | 400 | 1Password vaults can't be used in browser sessions. |
| `SECRET_SOURCES_INVALID` | 400 | The vault attached at session create has no entries. |
| `SECRET_VALUE_INVALID` | 422 | The stored value can't be used, such as an invalid TOTP seed. Add a new version. |
| `SESSION_NOT_ACTIVE` | 409 | The session has stopped. |
| `CREDENTIAL_FILL_TIMEOUT` | 503, 504 | The fill didn't complete in time. Retryable. |
| `CREDENTIAL_FILL_FAILED` | 500, 502 | The browser couldn't complete the fill. |
| `CREDENTIALS_UNSUPPORTED_RUNTIME`, `CREDENTIAL_FILL_UNSUPPORTED`, `CREDENTIAL_VALUE_UNSUPPORTED` | 400, 501 | The session's browser can't fill credentials, or can't produce this value type. |
| `CREDENTIALS_SNAPSHOT_PASSWORD_MANAGER_ENABLED` | 400 | The session restores a snapshot taken with Chrome's password manager on. |
| `SECRETS_FEATURE_DISABLED` | 403 | Secrets aren't enabled for your team yet. Please contact support to enable them. |

A fill can take up to about 18 seconds, so give your HTTP client a timeout of at least 30 seconds. The SDKs do this for you.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.