TOTP / 2FA Codes
Add TOTP secrets through Token Vault's dashboard — they route straight to your webhook, which generates one-time codes for agents. Token Vault never sees the secret.
Token Vault supports TOTP (Time-based One-Time Passwords) as a first-class token type. Store a 2FA secret from the dashboard — paste the otpauth:// URI from a QR code, or a raw Base32 secret — and it goes straight from your browser to your webhook, which encrypts and stores it. Grant an agent access the same way you'd grant any other token. When the agent requests the credential, it gets back a ready-to-use 6-digit code, generated fresh by your webhook — never the underlying secret. The dashboard's own TOTP display works the same way: a live code with a countdown, computed by your webhook via a signed ticket. The reference (TypeScript) webhook supports this out of the box; nothing to configure.
How it works
Supported input formats
| Format | Example | What happens |
|---|---|---|
| otpauth:// URI | otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30 | Secret, issuer, account, algorithm, digits, and period are all extracted automatically |
| Raw Base32 secret | JBSWY3DPEHPK3PXP | Stored with defaults: SHA1, 6 digits, 30-second period |
TOTP parameters
| Parameter | Default | Description |
|---|---|---|
| Algorithm | SHA1 | Hash algorithm (SHA1, SHA256, SHA512) |
| Digits | 6 | Code length (6, 7, or 8 digits) |
| Period | 30s | How long each code is valid |
| Issuer | — | Service that issued the secret (e.g., "GitHub") |
| Account | — | Account identifier (e.g., "user@example.com") |
Most services use the defaults. Non-standard configurations from the otpauth:// URI are stored and used automatically.
Security properties
| Property | How it's achieved |
|---|---|
| Secret never leaves webhook | Browser-direct storage; TV uses 307 redirect; webhook decrypts locally |
| Encrypted at rest | The reference webhook encrypts with AES-256-GCM — your webhook, your choice |
| Code-only responses | Webhook returns the generated code, never the raw secret |
| Policy-gated | Every code request goes through TV's ABAC engine first |
| Audited | AGENT_CREDENTIAL_ACCESS event logged per code generation |
| Revocable | Suspend agent or delete grant for instant cutoff |
| Time-limited | Codes expire after one period (typically 30 seconds) |
Troubleshooting
Code doesn't match the authenticator app
- Verify the webhook server's clock is accurate (TOTP is time-based)
- Check that the algorithm, digits, and period match the provider's settings
"Token is not a TOTP token"
- The token was stored with a different type. Re-add it as type "2FA" (TOTP)
Dashboard countdown shows wrong time
- The countdown is client-side based on
remainingSecondsfrom the webhook. If the webhook's clock is offset from the TOTP provider, codes may appear to expire early or late.
Implementing it in your own webhook
The reference TypeScript webhook already implements everything below — this section is only for a from-scratch or custom-language webhook. Advertise the totp capability at /v1/exchange and /v1/health, then implement the interception and the /v1/totp-code endpoint described in the webhook protocol reference.
This example uses Python and pyotp, but any language with an RFC 6238 TOTP library works identically.
Dependencies
pyotp>=2.9,<3TOTP code generation
Create a helper function that generates the current code from a decrypted secret:
import time
import pyotp
def generate_totp_code(secret: str, stored_doc: dict) -> dict:
"""Generate a TOTP code from a decrypted secret."""
meta = stored_doc.get("meta", {})
algorithm = meta.get("totpAlgorithm", "SHA1")
digits = meta.get("totpDigits", 6)
period = meta.get("totpPeriod", 30)
digest_map = {"SHA1": "sha1", "SHA256": "sha256", "SHA512": "sha512"}
digest_name = digest_map.get(algorithm.upper(), "sha1")
totp = pyotp.TOTP(secret, digits=digits, interval=period, digest=digest_name)
code = totp.now()
remaining = period - (int(time.time()) % period)
return {
"code": code,
"remainingSeconds": remaining,
"period": period,
"digits": digits,
}Wire into /v1/credential (interception)
In your webhook's credential endpoint, add the TOTP interception after decrypting the stored token but before any raw-credential interception:
# ... inside the credential handler, after decryption ...
token_type = token.get("tokenType", stored_doc.get("meta", {}).get("tokenType", ""))
if token_type == "TOTP" and token.get("totpSecret"):
try:
result = generate_totp_code(token["totpSecret"], stored_doc)
token = {
"accessToken": result["code"],
"tokenType": "TOTP",
"serviceName": service,
"remainingSeconds": result["remainingSeconds"],
"period": result["period"],
"digits": result["digits"],
"totpGenerated": True,
}
except Exception as totp_err:
return error_response(500, "totp_failed", f"Failed to generate TOTP code: {totp_err}")This pattern is identical to raw-credential brokering: detect after decryption, transform before returning. The raw TOTP secret is never sent back to the caller.
Dedicated /v1/totp-code endpoint
Implement a dedicated /v1/totp-code endpoint for the dashboard's live code display, following the same ticket verification pattern as /v1/credential:
@router.post("/v1/totp-code")
@router.get("/v1/totp-code")
async def totp_code(request: Request):
# 1. Extract ticket + service from request
# 2. Verify ticket (same as /v1/credential)
# 3. Decrypt TOTP secret from stored document
# 4. Generate code using generate_totp_code()
# 5. Return {code, remainingSeconds, period, digits}
...Sensitive field encryption
Ensure totpSecret is listed in your webhook's sensitive fields so it's encrypted at rest:
SENSITIVE_FIELDS = {
"accessToken", "refreshToken",
"certificateData", "privateKeyData", "certificateChain",
"sshPrivateKey",
"totpSecret", # TOTP secrets encrypted at rest
}Implementation troubleshooting:
- "Failed to generate TOTP code" — verify the secret is valid Base32 (uppercase letters A-Z and digits 2-7); check that your TOTP library is installed; if using an
otpauth://URI, verify it starts withotpauth://totp/. - Agent gets empty or null
accessToken— the webhook must have the TOTP interception code in/v1/credential; ensuretotpSecretis in your encrypted/sensitive fields list.
Ready to try it?
Sign up free with Google — your credentials stay on your own webhook, and the quickstart gets an agent fetching its first credential in about ten minutes.
Hardening Your Webhook
Rate limits, backoff, and budget alerts that keep a runaway agent from burning your webhook's quota — Token Vault's defaults plus the Cloudflare-side rules worth adding.
Raw Credential Brokering
Broker raw credentials (GCP service accounts, AWS credentials) through Token Vault — they live on your webhook, which mints short-lived access tokens for agents.