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

# TOTP handling

> How an agent completes an authenticator-app step without a stored secret ever leaving the backend, how agent enrolment hands a new secret to it, and how to verify it.

An agent can complete an authenticator-app sign-in step without ever seeing the
authenticator secret or the code.

## The guarantee

**A stored authenticator (TOTP) secret never leaves the Agent Vault backend. Only
the current code is derived from it, only for a fill on the login's registered
site. The backend returns that code to the proxy, which types it into the site,
so the proxy and the site receive the code. The agent and the account holder
never see it.**

The guarantee covers a secret once it is stored. Before that, during
[enrolment by an agent](#enrolment-by-an-agent), the site's page shows the new
`otpauth://` secret to the proxy, and the proxy reads it and sends it to the
backend to encrypt. The agent does not receive it on that path either.

A TOTP secret is long-lived and generates every future code, so it must never
enter an agent's context. But an agent that cannot complete a two-factor step
cannot sign in to most sites worth signing in to. Agent Vault resolves this by
letting the agent fill a token while the backend derives the code.

## How it works

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant A as Agent
    participant P as Proxy
    participant B as Agent Vault backend
    participant S as Site

    A->>P: fill(#code, av_login_github_totp_u7x2k)
    P->>B: Resolve this TOTP token for this host
    B->>B: Check the account, key scope and URL lock
    B->>B: Derive the current code from the stored secret
    B-->>P: The code for this time window
    P->>S: Types the code
    Note over A,B: The agent sees neither the secret nor the code.<br/>The proxy and the site receive the code, never the secret.
```

The secret stays in the backend, encrypted at rest. What crosses to the proxy is a
short-lived code, valid for the current window only.

<Note>
  Agent-facing tool descriptions summarise this as "the proxy derives the code".
  Precisely, the backend derives it and the proxy types it. What the agent can
  observe is the same either way: neither the secret nor the code.
</Note>

### What the agent knows

| It knows | It does not know |
| - | - |
| `hasTotp: true` on the login | The secret |
| A `tokens.totp` string to fill | The code |
| That the code is short-lived | The period or digit count, unless told |

A login without `tokens.totp` has no stored two-factor secret. A `_totp` token
that fails with "no matching record on file" means exactly that.

### Supported factors

| Supported | Not supported |
| - | - |
| Authenticator-app TOTP (RFC 6238) | SMS one-time codes |
| The standard 30-second, 6-digit window | Email one-time codes |
| A custom period or digit count from an imported `otpauth://` URI: 6 to 10 digits, a period of any whole number of seconds from 1, and SHA1, SHA256 or SHA512. Anything else is refused when you save the login | Passkeys |

**Submit promptly.** The code is valid for the current window only. If a site
requires an unsupported second factor, the sign-in cannot complete unattended,
and the agent should say so rather than retrying.

<Warning>
  Holding a `totp` token does **not** make an unrelated prompt the agent's to
  answer. Only the authenticator step of a sign-in the agent just performed, on
  that login, is. An SMS code, an email code or a payment verification at checkout
  belongs to the account holder. A code spent on the wrong prompt is wasted and can
  trip the site's retry limits.
</Warning>

### URL locking applies

A TOTP token is a login token. It resolves only on the login's registered
`website` or an `extraWebsites` entry, exactly like the password. See
[URL locking](/security/url-locking).

## Enrolment by an agent

There is exactly one path on which a secret moves, and it is **proxy to backend**,
never backend to proxy.

When an agent sets up an authenticator on a site, the site displays an
`otpauth://` URI on a page the proxy is already rendering. The proxy reads it
there and hands it to the backend to encrypt:

```ts theme={null}
await page.hint(
  "login.totp_secret",
  { selector: ".otpauth-link", read: "attribute", attribute: "href" },
  { login: "github" },
);
// -> { stored: true, digits: 6, period: 30 }
```

The secret is already on a screen the proxy controls, so reading it there
discloses nothing the proxy did not already have. The agent never receives it.

### Conditions for enrolment

All of these must hold. Three are checked before the page is read and fail
with their own code: a non-loopback page not on HTTPS (`insecure_page`), a session that has
filled no token yet, and a session that has not signed in as that login (both
`sensitive_data_blocked`). Any other failure returns one `hint_unresolved`,
without saying which condition failed.

<AccordionGroup>
  <Accordion title="The session must already have signed in as that login">
    The proxy refuses to read a setup secret in a session it was not already
    protecting, with `sensitive_data_blocked`. Until a token has been filled,
    screenshots are not blacked out, so the agent could otherwise see the secret.
    What counts is that login's **password** (or its two-factor code) landing in
    a field. Filling its username does not.
  </Accordion>

  <Accordion title="The account holder must not have two logins for this site">
    Two accounts on one site share a host, so nothing else could tell them apart,
    and a secret stored against the wrong one cannot be moved. The holder enrols
    from the dashboard instead, where they can choose the login.
  </Accordion>

  <Accordion title="The otpauth:// link must name the login's stored username">
    This binds the secret on screen to the record it is stored on. A bare base32
    key is refused: nothing in it says whose authenticator it is. Names are
    compared as written, so a login stored as `ada` does not match a link naming
    `ada@example.com`.
  </Accordion>

  <Accordion title="The element must be unique, visible, top-level and served over HTTPS">
    Exactly one match, so the backend never chooses between a secret and a
    recovery key beside it. Visible, so reveal the "can't scan the QR code?"
    option first: a hidden element can hold a stale key. Top-level rather than in
    an iframe, because the secret is authorized against the address of the page.
    HTTPS (or a loopback `http://` page, for a local demo), or the read fails
    with `insecure_page`. A QR code image or a
    `<canvas>` cannot be read; `href` is the only attribute this hint reads.
  </Accordion>

  <Accordion title="Existing two-factor secrets are never overwritten">
    A secret is stored only on a login that has none, and only on a site the
    login is registered for.
  </Accordion>
</AccordionGroup>

### Confirm the setup on the site

Most setup pages then ask for a code. Fetch the catalog again: the `totp` token is
now present. Do not skip this step. An unconfirmed setup leaves the site **without
two-factor protection** while Agent Vault holds a secret it will not overwrite,
which the account holder then has to resolve by hand.

### When the result is uncertain

`hint_uncertain` means the write reached the backend but the answer was lost, so
whether it stored is unknown. **Do not retry**: enrolment refuses to overwrite, so
a retry reports a second failure. Ask the account holder to check the login on
their [Logins](/dashboard/logins) page.

### The account holder is told, on a best-effort basis

After an agent enrolment, Agent Vault tries to send a `totp_enrolled`
notification. Enrolment is the only path that stores a credential the account
holder did not type, so the notification is meant to reach them every time.

The notification is best effort. The secret is stored first, and a failure to
create the notification does not undo it. So a stored secret can exist with no
Activity entry, email or push about it. The [Logins](/dashboard/logins) page is
the record of what is stored.

## What this does not protect against

* **A compromised dashboard account.** The holder's own view of their logins can
  add, change or remove a stored secret.
* **Exposure inside Agent Vault.** The backend decrypts the secret to derive each
  code, so Agent Vault's backend is inside the trust boundary.
* **The current code at the moment of the fill.** It is typed into the site's
  page and is valid until the window ends.
* **Unsupported factors.** SMS, email codes and passkeys remain with the account
  holder.

## How to verify

<Steps>
  <Step title="Add a test login with an authenticator secret">
    On the [Logins](/dashboard/logins) page, add a login for a test account and
    import its `otpauth://` URI.
  </Step>

  <Step title="Read the catalog">
    Call `get_token_catalog`. The login shows `hasTotp: true` and a
    `tokens.totp` string. No secret and no code appear anywhere in the response.
  </Step>

  <Step title="Fill the token off-site">
    Fill the `totp` token on a page that is not the login's site. The fill fails
    with `login_blocked`.
  </Step>

  <Step title="Fill it on the site">
    Fill it on the site's authenticator step. The sign-in completes, and
    [`get_run_report`](/guides/confirming-outcomes) records a TOTP fill by kind,
    with no value.
  </Step>
</Steps>

## Related

<CardGroup cols={2}>
  <Card title="Signing in" icon="right-to-bracket" href="/guides/sign-in">
    The agent-side guide to sign-in flows.
  </Card>

  <Card title="Page hints" icon="hand-pointer" href="/guides/page-hints">
    The hint mechanism enrolment uses.
  </Card>
</CardGroup>


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