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
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:
| 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 |
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)
pyotpPython package on the webhook
1. Store the TOTP Secret
Open the Token Vault dashboard and click Add Custom Token.
- Set Service name to something descriptive (e.g.,
github-2fa,aws-mfa) - Set Type to 2FA (the Timer icon, under Special types)
- Either:
- Paste the full
otpauth://URI (from a QR code) — all parameters are extracted automatically - Or enter the Base32 secret directly
- Paste the full
- 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
- Go to Agents and create or select an agent
- Click Grant Token and select the TOTP service (e.g.,
github-2fa) - Set an expiry if desired
- 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:
- Decrypts the stored TOTP secret
- Detects it's a TOTP token
- Generates the current RFC 6238 code using
pyotp - 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,<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 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:
| 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 (SHA1, 6 digits, 30 seconds). 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 | AES-256-GCM with webhook-owned key |
| 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
"Failed to generate TOTP code"
- Verify the secret is valid Base32 (uppercase letters A-Z and digits 2-7)
- Check that
pyotpis installed on the webhook (pip install pyotp) - 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 - Check webhook logs for
totp_detectedentries - Ensure
totpSecretis in the encrypted fields andSENSITIVE_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
ntpdorsystemd-timesyncdis 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
remainingSecondsfrom the webhook. If the webhook's clock is offset from the TOTP provider, codes may appear to expire early or late.