---
summary: Agent guide to discovering vault credentials, calling external APIs through the egress proxy, and signing short-lived SSH certificates – using a credential without ever reading its raw secret material.
tags:
  - developers
  - agents
  - vault
  - egress
  - ssh
categories:
  - AI Agents
---

# Use Vault Credentials And Egress Proxy

<!-- agent:page
You are a coding agent that has already authenticated a SigID agent principal and now needs to call a third-party API (or sign an SSH certificate) using a credential stored in the SigID vault.
The defining property of this surface: the agent USES a credential but never READS it. The credential's raw secret is decrypted only inside the egress boundary and injected onto the outbound request at a boundary the agent does not control. Revoking the grant or credential takes effect on the very next request.
There is no SDK method and no CLI command for these endpoints yet (both the JS @sigid/client and the Rust sigid-sdk cover authentication only). Use plain HTTP / curl. This page is the wire contract.
Order of operations:
1. Hold a delegated token whose scope includes `vault:list` (to discover credentials) and `vault:read` (for egress resolve) – see the Agent Self-Serve Quickstart.
2. GET /api/v1/vault/credentials to discover a credential_id whose `allowed_hosts` covers your target.
3. POST /v1/proxy with that credential_id + target URL; the egress injects the secret and forwards.
4. For SSH, POST /api/v1/agents/delegations/ssh-sign with an `ssh_ca` credential_id to get a short-lived certificate.
Do not request raw credential material via the RFC 8693 token-exchange export path when an egress injection path exists – prefer injection. Never log bearer tokens or response bodies that may contain upstream secrets.
-->

Use this page after your agent is authenticated and you need to **use a vault
credential** – call an external API through the egress proxy, or sign a
short-lived SSH certificate. The agent never sees the raw secret; SigID injects
it at the egress boundary.

This page continues where the
[Agent Self-Serve Quickstart](agent-quickstart.md) ends: you already have (or
can obtain) a delegated token. There is **no SDK method or CLI command** for
vault, egress, or SSH signing yet – both
[@sigid/client](sdks-examples.md) and the Rust SDK cover authentication only.
The examples below are plain HTTP, which is the authoritative wire contract
implemented by the `sigid-egress` and `sigid-server` route handlers.

## What You Are Building

1. A list of vault credentials your agent is authorized to use, each identified
   by a `credential_id` and constrained to specific upstream hosts.
2. An outbound call to a third-party API (`POST /v1/proxy`) where SigID injects
   the credential's secret and forwards – your agent supplies the request, never
   the secret.
3. Optionally, a short-lived SSH certificate signed by a vault-held SSH CA,
   for server access without distributing long-lived keys.

## Concepts

| Concept | Meaning |
|---|---|
| **credential_id** | UUID identifying a vault credential row. You pass this to egress / SSH signing; you never receive the underlying secret. |
| **credential_type** | `oauth2`, `api_key`, `ssh_key`, `ssh_ca`, or `custom`. Determines usage path. |
| **auth_scheme** | How the secret is injected: `bearer`, `basic`, `header:<name>`, or `query:<key>`. **Vault-authoritative** – set at credential creation, validated on use, the caller cannot override it. |
| **allowed_hosts** | Normalized hostnames the credential may be injected toward. The egress sends the SSRF-pinned target host; the core rejects injection toward any host not in this set. |
| **grant** | Binds a credential to an agent or delegation with a capability snapshot and optional expiry. A delegated runtime use requires `vault:read` scope **and** an active grant. |
| **non_exportable** | If set, the RFC 8693 export path and owner `GET .../token` refuse the credential; egress injection is its only usage channel. |
| **egress injection boundary** | The trust boundary where the decrypted secret lives momentarily in `sigid-egress` `sigid-memguard`-backed memory, zeroized after use. The agent operates outside this boundary. |

## Before You Start

| Value | Production | Local Docker Compose |
|---|---|---|
| IDP / identity host | `https://auth.sigid.org` | `http://auth.sigid.localhost:3000` |
| Egress host (`POST /v1/proxy`) | egress endpoint (often same front host via HAProxy) | `http://auth.sigid.localhost:3000` |
| Required token scopes | `vault:list` (discover), `vault:read` (egress resolve) | same |
| Token type | delegated agent access token | same |

```bash
export SIGID_IDP="${SIGID_IDP:-https://auth.sigid.org}"
# Delegated token (carrying vault:list + vault:read) – for vault list and egress:
export DELEGATED_TOKEN='<delegated agent access token>'
# Direct agent token – for SSH signing (ssh-sign rejects delegated JWTs):
export AGENT_TOKEN='<direct agent access token>'
```

Security rules for this entire page:

- Vault list and egress (`/v1/proxy`) need a **delegated** token carrying
  `vault:list` / `vault:read` and an active grant on the credential. SSH signing
  (`ssh-sign`) needs a **direct** agent token – it rejects delegated JWTs.
- Never log bearer tokens, `credential_id` is fine to log but the **injected
  secret and upstream response bodies may contain secret material** – do not
  echo full proxy responses into tickets or logs.
- Prefer egress injection over the RFC 8693 export path. If a credential is
  marked `non_exportable`, export is impossible by design – injection is the
  only channel.

## List Vault Credentials

Discover `credential_id` values your agent may use. **Scope: `vault:list`.**
Returns metadata only – never decrypted secrets.

```bash
curl -sS "$SIGID_IDP/api/v1/vault/credentials?limit=50" \
  -H "authorization: Bearer $DELEGATED_TOKEN"
```

**Response (shape)**

```json
[
  {
    "id": "018f3d44-7d4d-7d5d-8d3b-9f0c4c37a111",
    "tenant_id": "018f...",
    "tenant_name": "My Workspace",
    "provider": "deepseek",
    "credential_type": "api_key",
    "label": "production-key",
    "auth_scheme": "bearer",
    "allowed_hosts": ["api.deepseek.com"],
    "non_exportable": true,
    "status": "active",
    "metadata": {},
    "created_at": "2026-07-01T12:00:00Z",
    "updated_at": "2026-07-01T12:00:00Z",
    "last_used_at": null
  }
]
```

| Field | Semantics |
|---|---|
| `id` | The `credential_id` you pass to `/v1/proxy` or SSH signing |
| `credential_type` | Usage path: `api_key`/`custom`/`ssh_key` → egress; `ssh_ca` → SSH signing; `oauth2` → managed OAuth (separate `/oauth/token` exchange) |
| `auth_scheme` | Injection method; you may echo it on `/v1/proxy` but cannot change it |
| `allowed_hosts` | **Pick a credential whose `allowed_hosts` covers your target URL's host**, else egress rejects the call |
| `status` | Must be `active`; revoked credentials fail at resolve time |
| `non_exportable` | If true, only egress injection can use this credential |

List supports cursor pagination via `cursor_ts` + `cursor_id` query parameters.

## Call An API Through Egress

`POST /v1/proxy` forwards a caller-built request to an upstream API, injecting
the decrypted credential according to the vault-authoritative `auth_scheme`.
**Scope: `vault:read` + active grant.** Hosted at the egress service
(`sigid-egress`).

```bash
curl -sS "$SIGID_IDP/v1/proxy" \
  -X POST \
  -H "authorization: Bearer $DELEGATED_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "credential_id": "018f3d44-7d4d-7d5d-8d3b-9f0c4c37a111",
    "method": "POST",
    "url": "https://api.deepseek.com/v1/chat/completions",
    "headers": [["content-type", "application/json"]],
    "body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"
  }'
```

**Request fields**

| Field | Type | Notes |
|---|---|---|
| `credential_id` | string (UUID) | Required. Must be a valid UUID; the credential must be `active`, granted to your agent/delegation, and its `allowed_hosts` must cover the target host. |
| `method` | enum | `GET` / `POST` / `PUT` / `PATCH` / `DELETE` / `HEAD` / `OPTIONS` |
| `url` | string | Full HTTPS URL of the upstream target. SSRF-validated; the host **must** be in the credential's `allowed_hosts`. |
| `headers` | array of `[name, value]` pairs | Caller-supplied request headers. Auth/injection headers are added by egress per `auth_scheme`; do not duplicate them. Sanitized before forwarding. |
| `body` | string | UTF-8 request body. **Mutually exclusive with `body_base64`.** |
| `body_base64` | string | Base64-encoded body for binary payloads. **Mutually exclusive with `body`.** |
| `auth_scheme` | string (optional) | If supplied, must equal the stored scheme or the call is rejected. Omit to let the vault-authoritative scheme apply. |
| `stream` | boolean (optional) | Default `false`. When `true`, the upstream **response** body is forwarded chunk-by-chunk with no whole-response buffering. Use this for SSE / LLM completion streams. Request bodies stay JSON-buffered on this path. |

**Response**: the upstream API's response. With `stream: false` (default), the
body is buffered under a hard size cap. With `stream: true`, headers return as
soon as the upstream answers and chunks are piped through; mid-stream transfer
stops if a metered share exhausts its byte slice, the idle gap between chunks
exceeds ~90s, or the absolute hop timeout (default 5 minutes) elapses. When the
stream closes, egress records a post-stream usage envelope (bytes, chunks,
duration, outcome, advisory token counts when present) as an
`egress.stream.closed` audit event. Upstream response headers are replayed
through an allowlist in both modes.

### Streamed request body (`POST /v1/proxy/body`)

For large uploads that should not be base64-wrapped in JSON, use the raw-body
path. Metadata is carried in headers; the HTTP body is the upstream payload.

```bash
curl -sS "$EGRESS/v1/proxy/body" \
  -X POST \
  -H "authorization: Bearer $AGENT_TOKEN" \
  -H "x-sigid-credential-id: $CREDENTIAL_ID" \
  -H "x-sigid-url: https://api.example.com/v1/files" \
  -H "x-sigid-method: POST" \
  -H "content-type: application/octet-stream" \
  --data-binary @large.bin
```

| Header | Notes |
|---|---|
| `authorization` | Bearer agent/recipient token (required) |
| `x-sigid-credential-id` | Vault credential UUID |
| `x-sigid-url` | Full HTTPS upstream URL (SSRF + host binding) |
| `x-sigid-method` | `GET` / `POST` / `PUT` / `PATCH` / `DELETE` / `HEAD` / `OPTIONS` |
| `x-sigid-auth-scheme` | Optional; must match stored scheme if set |
| `x-sigid-stream-response` | Default `true`. Set `false` for a buffered upstream reply |
| `x-sigid-header-<name>` | Optional extra upstream request headers |
| `content-type` | Forwarded to upstream as its Content-Type |

Request bodies are capped at 32 MiB and count against a shared credential's
byte budget when a lease is held. Response streaming behaviour matches
`stream: true` on `/v1/proxy`.

The agent never receives the injected secret. The decrypted credential lives
only in `sigid-egress` memory for the duration of the request and is zeroized
after use.

## Sign An SSH Certificate

`POST /api/v1/agents/delegations/ssh-sign` signs a short-lived SSH certificate
using a vault-held **`ssh_ca`** credential. Requires a **direct agent
principal** (not a delegated JWT). The agent never receives the CA private key.

```bash
curl -sS "$SIGID_IDP/api/v1/agents/delegations/ssh-sign" \
  -X POST \
  -H "authorization: Bearer $AGENT_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "credential_id": "018f3d44-aaaa-7d5d-8d3b-9f0c4c37aaaa",
    "target_host": "deploy.example.com",
    "principals": ["deploy"],
    "validity_seconds": 300
  }'
```

**Request fields**

| Field | Type | Notes |
|---|---|---|
| `credential_id` | string | The `ssh_ca` credential id. Must be `active` and owned by the agent. |
| `target_host` | string | Host the certificate is valid for. |
| `principals` | array of strings | SSH principals (usernames) the certificate authorizes. |
| `validity_seconds` | u64 | Certificate lifetime. Default 300s (5 min), max 3600s (1 hour). |

The certificate `key_id` is server-generated (`sigid:<tenant>:<agent>`); the
caller does not supply it.

**Response (shape)**

```json
{
  "certificate": "ssh-ed25519-cert-v01@openssh.com AAAA...",
  "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----...",
  "public_key": "ssh-ed25519 AAAA...",
  "valid_until": "2026-08-02T12:05:00Z",
  "fingerprint": "SHA256:..."
}
```

| Field | Semantics |
|---|---|
| `certificate` | The short-lived SSH certificate to present to the target host |
| `private_key` | The ephemeral private key backing the certificate (for `-i`) |
| `public_key` | The corresponding ephemeral public key |
| `valid_until` | Certificate expiry timestamp |
| `fingerprint` | SHA-256 fingerprint of the ephemeral public key |

**Using the certificate**

Write the returned material to local files, then connect with `ssh -i` +
`CertificateFile` (the response field comments above map directly to these
flags):

```bash
printf '%s\n' "$CERTIFICATE" > agent-cert.pub
printf '%s\n' "$PRIVATE_KEY"  > agent-key && chmod 600 agent-key
ssh -i agent-key -o CertificateFile=agent-cert.pub deploy@deploy.example.com
```

The certificate authorizes only the `principals` you requested and expires at
`valid_until`; reconnect after expiry by requesting a new signature.

This is the only SSH path exposed by the HTTP egress; it does not provide an
`ssh-agent` socket signer. SSH key credentials (`ssh_key` type) are injected
via `/v1/proxy` like any static credential; only `ssh_ca` credentials sign
certificates here.

## Token Exchange And Refresh

Vault list and egress require scopes your agent does **not** hold by default:
`vault:list` (discover credentials) and `vault:read` (egress resolve). Both
are in the agent allowlist, so there are two ways to obtain them.

**Path A – request at challenge auth (direct token).** Ask for the scopes when
authenticating; the direct token then carries them:

```bash
sigid-cli auth --scope "vault:list vault:read"
```

**Path B – exchange from an owner delegation (delegated token).** Once an owner
grants the agent a delegation that includes these scopes (one-time, via
[device-flow delegation](agent-quickstart.md) or the Identity UI), the agent
can mint a delegated token on demand – no human re-approval:

```bash
curl -sS "$SIGID_IDP/api/v1/agents/delegations/token" \
  -X POST \
  -H "authorization: Bearer $AGENT_TOKEN" \
  -H "content-type: application/json" \
  -d '{ "scope": "vault:list vault:read" }'
```

The requested scope must be a subset of the delegation's scopes. See
[Delegation](delegation.md).

**Refresh** a delegated token via `/oauth/token` with
`grant_type=refresh_token` (the refresh token comes from the exchange or the
device-flow poll).

Keep the direct agent token (Path A; also used for SSH signing) and the
delegated token (Path B) **separate** – do not overwrite one cache entry with
the other.

## Failure Modes

| Symptom | Cause | What to do |
|---|---|---|
| `GET /vault/credentials` → `403 access_denied` | Missing `vault:list` scope, or no active grant for the credential | Re-run device delegation requesting `vault:list`; have the owner grant the credential to your agent |
| `GET /vault/credentials` → `501` `feature_disabled` | Vault service not configured on the tenant | Contact operator to enable vault |
| `POST /v1/proxy` → `401` | Missing `Authorization` header | Send `Authorization: Bearer <token>` |
| `POST /v1/proxy` → `400` | Bad request body: `credential_id` not a UUID, both `body` and `body_base64` set, invalid `method`, non-HTTPS `url`, too many headers, or non-Bearer `Authorization` scheme | Validate inputs against the request schema above |
| `POST /v1/proxy` → `429` | Per-caller rate limit or in-flight concurrency cap on egress | Back off; reduce request rate / concurrency |
| `POST /v1/proxy` → `502` | Vault resolve failed: invalid/expired token, missing `vault:read` scope, no active grant, host not in `allowed_hosts`, `auth_scheme` mismatch, credential revoked, or internal error | Verify token validity + scope + freshness, grant active, host allowed, `auth_scheme` correct (or omit it); check core logs |
| `ssh-sign` → `403 access_denied` | Used a delegated JWT or non-agent principal | Re-authenticate as the agent directly (`sigid-cli auth`); SSH signing requires the direct agent token |
| `ssh-sign` → `400` | Invalid `credential_id`, credential not `ssh_ca`, or `validity_seconds` > 3600 | Use a `ssh_ca` credential id you own; keep validity ≤ 3600 (300 default for least privilege) |
| `ssh-sign` → `404` / vault not configured | Vault service disabled | Enable vault / contact operator |

## Security Checklist

- [ ] Delegated token carries `vault:list` + `vault:read`; direct agent token kept separate for SSH signing
- [ ] Each egress target host is covered by the credential's `allowed_hosts`
- [ ] No bearer tokens, injected secrets, or full upstream response bodies logged
- [ ] `non_exportable` credentials preferred – injection, not export
- [ ] SSH `validity_seconds` kept to the minimum needed (default 300s)
- [ ] Grants revoked when an agent no longer needs a credential
- [ ] Egress calls respect per-caller rate limits (back off on 429)

## See Also

- [Agent Self-Serve Quickstart](agent-quickstart.md) – authentication and delegated token that precedes this page
- [Delegation](delegation.md) – token exchange (RFC 8693) and refresh
- [Agent And MCP Auth](agents-mcp.md) – agent integration overview
