Token Vault
Your Webhook

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

Loading diagram...

Supported input formats

FormatExampleWhat happens
otpauth:// URIotpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30Secret, issuer, account, algorithm, digits, and period are all extracted automatically
Raw Base32 secretJBSWY3DPEHPK3PXPStored with defaults: SHA1, 6 digits, 30-second period

TOTP parameters

ParameterDefaultDescription
AlgorithmSHA1Hash algorithm (SHA1, SHA256, SHA512)
Digits6Code length (6, 7, or 8 digits)
Period30sHow 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

PropertyHow it's achieved
Secret never leaves webhookBrowser-direct storage; TV uses 307 redirect; webhook decrypts locally
Encrypted at restThe reference webhook encrypts with AES-256-GCM — your webhook, your choice
Code-only responsesWebhook returns the generated code, never the raw secret
Policy-gatedEvery code request goes through TV's ABAC engine first
AuditedAGENT_CREDENTIAL_ACCESS event logged per code generation
RevocableSuspend agent or delete grant for instant cutoff
Time-limitedCodes 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 remainingSeconds from 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,<3

TOTP 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 with otpauth://totp/.
  • Agent gets empty or null accessToken — the webhook must have the TOTP interception code in /v1/credential; ensure totpSecret is 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.

On this page