Raw Credential Brokering
Store raw credentials (GCP service accounts, AWS credentials) in Token Vault and let your webhook mint short-lived access tokens for agents.
Token Vault can act as a credential broker for service account keys and other raw credentials. Instead of giving agents permanent access to raw keys, your webhook mints short-lived access tokens on demand.
How It Works
The agent receives a short-lived OAuth2 access token. The raw service account key never leaves the webhook.
Supported Credential Types
The auto-detection works for any credential stored as a Raw Credential token type:
| Credential | Detection | Webhook Behavior |
|---|---|---|
| GCP Service Account | type: "service_account" in JSON | Mints 1-hour OAuth2 access token via google-auth |
| GCP Authorized User | type: "authorized_user" in JSON | Returns as-is (or mint via refresh token) |
| AWS Credentials | aws_access_key_id in JSON or INI | Extendable: mint STS session tokens |
| Azure Service Principal | appId + tenant in JSON | Extendable: mint Azure AD tokens |
| Other JSON/YAML | Generic detection | Returns as-is |
GCP Service Account minting is built into the reference webhook. AWS and Azure support can be added using the same pattern.
Step-by-Step Setup
Prerequisites
- Token Vault account with Webhook Mode configured and bound
- A running webhook (reference implementation or custom)
- For GCP minting:
google-authandrequestsPython packages on the webhook
1. Store the Raw Credential
Open the Token Vault dashboard and click Add Custom Token.
- Set Service name to something descriptive (e.g.,
gcp-prod,gcp-readonly) - Set Type to Raw (the Raw Credential button)
- Paste the credential content (e.g., GCP service account JSON) into the text area
- The auto-detector will show a badge like
GCP Service Accountif it recognises the format - Click Encrypt & Store
The credential 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 raw credential service (e.g.,
gcp-prod) - Set an expiry if desired
- The agent now has a
tvagent_...API key that can request this credential
3. Agent Retrieves a Short-Lived Token
The agent calls Token Vault to get credentials:
curl -L -H "Authorization: Bearer tvagent_abc123..." \
"https://api.tokenvault.uk/api/agents/credentials?service=gcp-prod"Token Vault validates the API key, checks policies, and 307-redirects to the webhook. The webhook:
- Decrypts the stored credential
- Detects it's a GCP service account
- Signs a JWT and exchanges it for a 1-hour access token
- Returns only the access token
Response:
{
"accessToken": "ya29.c.b0AXv0zTP...",
"tokenType": "Bearer",
"expiresAt": "2026-02-26T15:30:00Z",
"serviceName": "gcp-prod",
"serviceAccount": "sa@project.iam.gserviceaccount.com",
"gcpMinted": true
}4. Use with MCP Proxy
You can also use raw credential tokens through the MCP proxy. Configure a proxy with:
- Service:
gcp-prod - Upstream URL:
https://storage.googleapis.com/... - Header template:
Authorization: Bearer ${TOKEN}
The webhook will mint an access token and inject it into the upstream request automatically.
Webhook Implementation
This section explains the code changes needed on your webhook to support credential brokering. The reference webhook at examples/webhook-ngrok/ already includes this.
Dependencies
Add google-auth and requests to your webhook's requirements.txt:
google-auth
requestsrequests is required as the HTTP transport for google-auth token minting.
GCP Minting Module
Create a module (e.g., gcp.py) that detects GCP service account JSON and mints short-lived tokens:
import json
import time
from typing import Optional
from cachetools import TTLCache
from google.auth.transport.requests import Request as GoogleAuthRequest
from google.oauth2 import service_account
# Cache minted tokens: key = (service_name, scopes_tuple)
# TTL 50 min (tokens last 60 min, mint fresh 10 min before expiry)
_token_cache: TTLCache = TTLCache(maxsize=100, ttl=3000)
DEFAULT_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
def is_gcp_service_account(credential_value: str) -> bool:
"""Check if a decrypted credential looks like a GCP SA JSON key."""
try:
data = json.loads(credential_value)
return (
isinstance(data, dict)
and data.get("type") == "service_account"
and "private_key" in data
and "client_email" in data
)
except (json.JSONDecodeError, TypeError, ValueError):
return False
def mint_access_token(
credential_value: str,
service_name: str,
scopes: Optional[list[str]] = None,
rid: str = "",
) -> dict:
"""Mint a short-lived GCP access token from a service account JSON key."""
scopes = scopes or DEFAULT_SCOPES
cache_key = (service_name, tuple(sorted(scopes)))
# Check cache
cached = _token_cache.get(cache_key)
if cached:
token, expiry_iso, email = cached
return {
"accessToken": token,
"expiresAt": expiry_iso,
"tokenType": "Bearer",
"serviceAccount": email,
}
# Parse SA JSON and mint
sa_info = json.loads(credential_value)
email = sa_info.get("client_email", "unknown")
credentials = service_account.Credentials.from_service_account_info(
sa_info, scopes=scopes,
)
credentials.refresh(GoogleAuthRequest())
access_token = credentials.token
expiry = credentials.expiry # datetime in UTC
if expiry:
expiry_iso = expiry.isoformat() + "Z"
else:
expiry_iso = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 3600)
)
_token_cache[cache_key] = (access_token, expiry_iso, email)
return {
"accessToken": access_token,
"expiresAt": expiry_iso,
"tokenType": "Bearer",
"serviceAccount": email,
}Wire Into /v1/credential
In your webhook's credential endpoint, add the interception after decrypting the stored token but before returning the response:
from gcp import is_gcp_service_account, mint_access_token
# ... inside the credential handler, after decryption ...
raw_access = token.get("accessToken", "")
if raw_access and is_gcp_service_account(raw_access):
try:
# Optional: parse scopes from query param
scopes_param = request.query_params.get("scopes", "")
scopes = [s.strip() for s in scopes_param.split(",") if s.strip()] or None
minted = mint_access_token(raw_access, service, scopes=scopes, rid=rid)
token = {
"accessToken": minted["accessToken"],
"tokenType": minted["tokenType"],
"expiresAt": minted["expiresAt"],
"serviceName": service,
"serviceAccount": minted["serviceAccount"],
"gcpMinted": True,
}
except Exception as gcp_err:
return error_response(
500, "gcp_mint_failed",
f"Failed to mint GCP access token: {gcp_err}",
)Wire Into /v1/proxy
In your webhook's proxy endpoint, add the same interception after decrypting the credential and before injecting it into upstream headers:
from gcp import is_gcp_service_account, mint_access_token
# ... inside the proxy handler, after decrypting access_token ...
if is_gcp_service_account(access_token):
try:
minted = mint_access_token(access_token, service, rid=rid)
access_token = minted["accessToken"]
except Exception as gcp_err:
return error_response(
500, "gcp_mint_failed",
f"Failed to mint GCP access token: {gcp_err}",
)
# ... then inject access_token into upstream headers as before ...The proxy interception is simpler — you only need the accessToken string since it's being injected into the upstream ${TOKEN} placeholder.
Adding Custom Credential Types
The pattern is always the same: detect after decryption, transform before returning. To add support for a new credential type (e.g., AWS STS):
1. Add a detector
def is_aws_credentials(value: str) -> bool:
try:
data = json.loads(value)
return bool(
data.get("aws_access_key_id")
and data.get("aws_secret_access_key")
)
except (json.JSONDecodeError, TypeError):
return False2. Add a minter
import boto3
def mint_aws_session_token(
credential_value: str, service_name: str, rid: str = ""
) -> dict:
creds = json.loads(credential_value)
sts = boto3.client(
"sts",
aws_access_key_id=creds["aws_access_key_id"],
aws_secret_access_key=creds["aws_secret_access_key"],
)
session = sts.get_session_token(DurationSeconds=3600)["Credentials"]
return {
"accessToken": session["AccessKeyId"],
"secretAccessKey": session["SecretAccessKey"],
"sessionToken": session["SessionToken"],
"expiresAt": session["Expiration"].isoformat(),
}3. Wire it in
Add the check alongside the GCP one in both /v1/credential and /v1/proxy:
if is_aws_credentials(raw_access):
minted = mint_aws_session_token(raw_access, service, rid=rid)
token = { ... }
elif is_gcp_service_account(raw_access):
...Security Properties
| Property | How It's Achieved |
|---|---|
| Key never leaves webhook | Browser-direct storage; TV uses 307 redirect; webhook decrypts locally |
| Encrypted at rest | AES-256-GCM with webhook-owned key |
| Short-lived tokens | Minted tokens expire in 1 hour (GCP default) |
| Policy-gated | Every mint request goes through TV's ABAC engine first |
| Audited | AGENT_CREDENTIAL_ACCESS event logged per mint |
| Revocable | Suspend agent or delete grant for instant cutoff |
Caching
The webhook caches minted tokens for 50 minutes (GCP tokens last 60 minutes). This means:
- First request: ~500ms (network call to Google's token endpoint)
- Subsequent requests within 50 min: instant (cache hit)
- Cache key:
(service_name, scopes)— different scope configurations get different cached tokens
Troubleshooting
"Failed to mint GCP access token"
- Verify the SA JSON is valid: it needs
type,project_id,client_email, andprivate_key - Check that
google-authandrequestsare installed on the webhook - Ensure the SA has the necessary IAM roles for the requested scopes
Agent gets raw JSON instead of a minted token
- The webhook must have the GCP interception code in both
/v1/credentialand/v1/proxy - Check webhook logs for
gcp_sa_detectedentries - Make sure you're checking
is_gcp_service_account()on the decryptedaccessTokenvalue
Token expired immediately
- The minted token inherits the SA's permissions. If the SA is disabled in GCP IAM, the token won't work even if it hasn't expired yet
ImportError: requests on webhook startup
google-authrequiresrequestsas a transport. Addrequeststo your webhook'srequirements.txtand rebuild.
Deploy Your Own Webhook with ngrok
Step-by-step guide to deploying a Token Vault webhook server locally using Docker and ngrok, binding it to your vault, and storing your first credential.
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.