Token Vault
Guides

TOTP / 2FA Codes

Store TOTP secrets in Token Vault and let your webhook generate one-time codes for agents — without Token Vault ever seeing the secret.

Token Vault supports TOTP (Time-based One-Time Passwords) as a first-class token type. Store a 2FA secret, and agents receive a fresh one-time code every time they request the credential — just like any other token.

How It Works

Loading diagram...

The agent receives a ready-to-use one-time code. The TOTP secret never leaves the webhook.

Supported Input Formats

TOTP secrets can be entered in two ways when adding a token:

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

The otpauth:// URI is the standard format embedded in QR codes shown by services like GitHub, Google, AWS, etc. If you can scan the QR code and extract the URI, paste the full URI for the best experience.

Step-by-Step Setup

Prerequisites

  • Token Vault account with Webhook Mode configured and bound
  • A running webhook (reference implementation or custom)
  • pyotp Python package on the webhook

1. Store the TOTP Secret

Open the Token Vault dashboard and click Add Custom Token.

  1. Set Service name to something descriptive (e.g., github-2fa, aws-mfa)
  2. Set Type to 2FA (the Timer icon, under Special types)
  3. Either:
    • Paste the full otpauth:// URI (from a QR code) — all parameters are extracted automatically
    • Or enter the Base32 secret directly
  4. Click Encrypt & Store

The secret goes directly from your browser to your webhook (Token Vault never sees it). The webhook encrypts it with AES-256-GCM and stores it on disk.

2. Grant an Agent Access

  1. Go to Agents and create or select an agent
  2. Click Grant Token and select the TOTP service (e.g., github-2fa)
  3. Set an expiry if desired
  4. The agent now has a tvagent_... API key that can request this credential

3. Agent Retrieves a One-Time Code

The agent calls Token Vault to get the current code:

curl -L -H "Authorization: Bearer tvagent_abc123..." \
  "https://api.tokenvault.uk/api/agents/credentials?service=github-2fa"

Token Vault validates the API key, checks policies, and 307-redirects to the webhook. The webhook:

  1. Decrypts the stored TOTP secret
  2. Detects it's a TOTP token
  3. Generates the current RFC 6238 code using pyotp
  4. Returns only the code (not the secret)

Response:

{
  "accessToken": "482916",
  "tokenType": "TOTP",
  "serviceName": "github-2fa",
  "remainingSeconds": 18,
  "period": 30,
  "digits": 6,
  "totpGenerated": true
}

The remainingSeconds field tells the agent how long the code is valid. After the period elapses, the agent should request a new code.

4. Dashboard Live Code Display

The dashboard shows a live TOTP code with a circular countdown timer. In webhook mode, the dashboard calls your webhook directly using a signed ticket — Token Vault never generates or sees the code.


Webhook Implementation

This section explains the code needed on your webhook to support TOTP. The reference webhook at examples/webhook-ngrok/ already includes this.

Dependencies

Add pyotp to your webhook's requirements.txt:

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 the GCP/Raw 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 GCP credential brokering: detect after decryption, transform before returning. The raw TOTP secret is never sent back to the caller.

Dedicated /v1/totp-code Endpoint (Optional)

The reference webhook also includes a dedicated /v1/totp-code endpoint for the dashboard's live code display. This follows 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
}

TOTP Parameters

The TOTP standard (RFC 6238) supports several configurable parameters. Token Vault preserves all of them:

ParameterDefaultDescription
AlgorithmSHA1Hash algorithm (SHA1, SHA256, SHA512)
Digits6Code length (6, 7, or 8 digits)
Period30sHow long each code is valid
IssuerService that issued the secret (e.g., "GitHub")
AccountAccount identifier (e.g., "user@example.com")

Most services use the defaults (SHA1, 6 digits, 30 seconds). 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 restAES-256-GCM with webhook-owned key
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

"Failed to generate TOTP code"

  • Verify the secret is valid Base32 (uppercase letters A-Z and digits 2-7)
  • Check that pyotp is installed on the webhook (pip install pyotp)
  • 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
  • Check webhook logs for totp_detected entries
  • Ensure totpSecret is in the encrypted fields and SENSITIVE_FIELDS

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
  • NTP sync issues can cause 1-code offsets; ensure ntpd or systemd-timesyncd is running

"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.

On this page