Token Vault
Guides

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

Loading diagram...

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:

CredentialDetectionWebhook Behavior
GCP Service Accounttype: "service_account" in JSONMints 1-hour OAuth2 access token via google-auth
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

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-auth and requests Python packages on the webhook

1. Store the Raw Credential

Open the Token Vault dashboard and click Add Custom Token.

  1. Set Service name to something descriptive (e.g., gcp-prod, gcp-readonly)
  2. Set Type to Raw (the Raw Credential button)
  3. Paste the credential content (e.g., GCP service account JSON) into the text area
  4. The auto-detector will show a badge like GCP Service Account if it recognises the format
  5. 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

  1. Go to Agents and create or select an agent
  2. Click Grant Token and select the raw credential service (e.g., gcp-prod)
  3. Set an expiry if desired
  4. 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:

  1. Decrypts the stored credential
  2. Detects it's a GCP service account
  3. Signs a JWT and exchanges it for a 1-hour access token
  4. 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
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):

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 False

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

PropertyHow It's Achieved
Key never leaves webhookBrowser-direct storage; TV uses 307 redirect; webhook decrypts locally
Encrypted at restAES-256-GCM with webhook-owned key
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

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, and private_key
  • Check that google-auth and requests are 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/credential and /v1/proxy
  • Check webhook logs for gcp_sa_detected entries
  • Make sure you're checking is_gcp_service_account() on the decrypted accessToken value

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-auth requires requests as a transport. Add requests to your webhook's requirements.txt and rebuild.

On this page