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.
Token Vault can act as a credential broker for service account keys and other raw credentials, instead of giving agents permanent access to a raw key. Store the credential (e.g. a GCP service account JSON) as a Raw Credential token — 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, your webhook detects the credential type, mints a short-lived access token (a 1-hour GCP OAuth2 token, for example), and returns only that — never the long-lived key. The same minting also works through the MCP proxy. GCP service-account minting is built into the reference webhook; AWS and Azure follow the same pattern if you need them.
How it works
Supported credential types
Auto-detection works for any credential stored as a Raw Credential token type:
| Credential | Detection | Webhook behaviour |
|---|---|---|
| GCP Service Account | type: "service_account" in JSON | Mints 1-hour OAuth2 access token |
| 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 |
Using with the MCP proxy
Configure a proxy with:
- Service:
gcp-prod - Upstream URL:
https://storage.googleapis.com/... - Header template:
Authorization: Bearer ${TOKEN}
Your webhook mints an access token and injects it into the upstream request automatically.
Security properties
| Property | How it's achieved |
|---|---|
| Key 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 |
| 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 |
Troubleshooting
"Failed to mint GCP access token"
- Verify the SA JSON is valid: it needs
type,project_id,client_email, andprivate_key - Ensure the SA has the necessary IAM roles for the requested scopes
Agent gets raw JSON instead of a minted token
- Your webhook needs the interception code on both the credential and proxy paths — see below if you're implementing your own
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
Implementing it in your own webhook
The reference TypeScript webhook already implements GCP service-account minting — this section is for a from-scratch or custom-language webhook, or for adding a new credential type. This example uses Python.
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):
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 Falseimport 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(),
}Wire it in alongside the GCP check 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):
...Caching
The reference pattern caches minted tokens for 50 minutes (GCP tokens last 60 minutes):
- 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
Implementation troubleshooting:
- Check that
google-authandrequestsare installed on the webhook —google-authrequiresrequestsas a transport. - Confirm you're checking
is_gcp_service_account()on the decryptedaccessTokenvalue, and that the interception exists on both/v1/credentialand/v1/proxy.
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.
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.
Binding Your Webhook
The one-time handshake that pairs your deployed webhook with your Token Vault account — bind page, one-time code exchange, HMAC establishment, and troubleshooting.