Token Vault
Your Webhook

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

Loading diagram...

Supported credential types

Auto-detection works for any credential stored as a Raw Credential token type:

CredentialDetectionWebhook behaviour
GCP Service Accounttype: "service_account" in JSONMints 1-hour OAuth2 access token
GCP Authorized Usertype: "authorized_user" in JSONReturns as-is (or mint via refresh token)
AWS Credentialsaws_access_key_id in JSON or INIExtendable: mint STS session tokens
Azure Service PrincipalappId + tenant in JSONExtendable: mint Azure AD tokens
Other JSON/YAMLGeneric detectionReturns 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

PropertyHow it's achieved
Key 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
Short-lived tokensMinted tokens expire in 1 hour (GCP default)
Policy-gatedEvery mint request goes through TV's ABAC engine first
AuditedAGENT_CREDENTIAL_ACCESS event logged per mint
RevocableSuspend 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, and private_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
requests

requests 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 False
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(),
    }

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-auth and requests are installed on the webhook — google-auth requires requests as a transport.
  • Confirm you're checking is_gcp_service_account() on the decrypted accessToken value, and that the interception exists on both /v1/credential and /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.

On this page