Composio Keyring credential proxy. Built and published from github.com/ComposioHQ/keyring.
100K+
Keyring is a credential-holding proxy: the one place where your third-party API credentials exist in plaintext. You store secrets as encrypted envelopes that only keyring's KMS-backed keys can open, and you use them by forwarding API requests through keyring, which decrypts each secret in-flight, injects it into the outbound request, and streams the provider's response back.
Plaintext credentials never leave the proxy except to the provider they belong to. Anything secret coming back — token exchanges, refreshes — is encrypted before it returns to you.
Source and issues: https://github.com/ComposioHQ/keyring
| Tag | Meaning |
|---|---|
alpha | Moving tag, rebuilt on every push to master. Not for production. |
X.Y.Z-alpha.<date>.<n> | Immutable pre-release build. |
X.Y.Z | Immutable release build. |
latest | Most recent stable release. |
Pin by digest in production. alpha and latest move under you; a digest does not.
Every image carries SLSA provenance and SPDX SBOM attestations (runtime and bundled dependencies), signed and pushed to the registry:
gh attestation verify oci://composiohq/keyring:<tag> --repo ComposioHQ/keyring
| Property | Value |
|---|---|
| Base | gcr.io/distroless/cc-debian13:nonroot |
| Runtime | Node 24 (official Docker binary, exact pinned version) |
| User | nonroot, uid 65532 |
| Port | 7464 (HOST defaults to 0.0.0.0 in the image) |
| Shell | None — no shell, no package manager, no curl |
| Entrypoint | /usr/local/bin/node --enable-source-maps dist/node.mjs |
Because there is no shell, anything running a command in the container must exec the node binary directly. Use the exec form, never CMD-SHELL:
["CMD", "/usr/local/bin/node", "/app/healthcheck.mjs"]
docker exec /bin/sh will not work, by design.
Provision these first. Keyring validates configuration aggressively and will refuse to serve traffic if any of them is wrong.
Keyring needs three distinct keys, and the config schema rejects any overlap between them.
| Config section | Key type | Purpose |
|---|---|---|
credential | Symmetric (AES, encrypt/decrypt) | Wraps the DEKs that encrypt credential payloads. |
authorization_gate | Symmetric (AES, encrypt/decrypt) | Wraps the DEKs for the outer consent-gate layer. |
secret_transfer | Asymmetric RSA (RSA-OAEP-256) | Clients seal secrets to the public key; the private key never leaves KMS. |
The two symmetric sections must use separate KMS keys and separate adapter ids — enforced at config load, not a convention. Adapter ids must match ^[a-z0-9][a-z0-9_-]*$.
The asymmetric key is what lets you hand keyring a secret without its operator ever seeing it: your client seals to the public key, and only KMS can unseal it.
On AWS:
# Two symmetric CMKs — credential and authorization_gate must be DIFFERENT keys
aws kms create-key --key-usage ENCRYPT_DECRYPT --key-spec SYMMETRIC_DEFAULT
aws kms create-key --key-usage ENCRYPT_DECRYPT --key-spec SYMMETRIC_DEFAULT
# One asymmetric RSA key — secret_transfer
aws kms create-key --key-usage ENCRYPT_DECRYPT --key-spec RSA_3072
RSA_2048, RSA_3072, and RSA_4096 are all accepted. The transfer key is validated on first use: KeyUsage must be ENCRYPT_DECRYPT and the key must advertise RSAES_OAEP_SHA_256, otherwise the JWKS route fails.
Grant the task role exactly this — nothing more:
| Key | Actions |
|---|---|
| credential CMK, authorization_gate CMK | kms:Encrypt, kms:Decrypt |
| secret_transfer RSA key | kms:GetPublicKey, kms:Decrypt |
kms:GenerateDataKey is not required. Keyring generates DEKs locally with a CSPRNG and wraps them with kms:Encrypt; it never calls GenerateDataKey. If you have seen that permission recommended elsewhere, it is stale — do not grant it.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:Decrypt"],
"Resource": [
"arn:aws:kms:REGION:ACCOUNT:key/CREDENTIAL_KEY_ID",
"arn:aws:kms:REGION:ACCOUNT:key/GATE_KEY_ID"
]
},
{
"Effect": "Allow",
"Action": ["kms:GetPublicKey", "kms:Decrypt"],
"Resource": "arn:aws:kms:REGION:ACCOUNT:key/TRANSFER_KEY_ID"
}
]
}
Other backends: GCP Cloud KMS (transfer keys need the full cryptoKeyVersions/N resource name and RSA_DECRYPT_OAEP_{2048,3072,4096}_SHA256) and HashiCorp Vault Transit (transfer key type rsa-2048|3072|4096, decryption enabled).
Keyring's allowlist decides which hosts each toolkit's credentials may be sent to. You do not have to configure one. Leave origin_policy unset and the image enforces the manifest compiled into it at build time — a snapshot covering ~1,070 toolkits — so a deployment always has a real allowlist rather than silently skipping the origin, exchange-endpoint, and body-secret guards.
Which source is active is visible at startup on the Loaded encryption services log line: config.origin_policy.source is bundled or remote.
Use the bundled default when the toolkits you call are in the snapshot and you are content to update the allowlist by upgrading the image.
Set origin_policy.manifest_url to enforce your own copy when you need registry changes sooner than your next image upgrade, or when you call a toolkit the snapshot does not list — self-hosted apps and apps configured per connection have no fixed address, so they are absent from it and requests to them are denied.
"origin_policy": { "manifest_url": "https://config.example.com/keyring/origin-manifest.json" }
A remote manifest is fetched over HTTP, cached for 60 seconds, and must use HTTPS in production. The bundled one is read from memory and has neither constraint.
Manifest format:
{
"toolkits": {
"example_toolkit": {
"allowed_origins": ["https://api.example.com", "https://*.example.com"],
"exchange_endpoints": [
{
"url": "https://api.example.com/oauth/token",
"sensitive_fields": [["access_token"], ["refresh_token"]]
}
]
}
}
}
allowed_origins is required per toolkit. exchange_endpoints marks token endpoints, which are then blocked from the general forwarding route and always encrypted on the way back. An optional body_secrets grant is the only way a secret may be resolved inside a forward request body — its absence denies every secret in a forward body.
Note that origin matching is exact on scheme, host, and port; * wildcards apply to host labels only.
Every /api/v1/* call must present Authorization: Bearer <jwt>. Your issuer must produce tokens that satisfy all of the following:
| Requirement | Rule |
|---|---|
| Signing algorithm | One of RS256, RS384, RS512, ES256, ES384, ES512. Default accepted: RS256. |
iss | Must equal AUTH_ISSUER. |
aud | Must equal AUTH_AUDIENCE. |
exp, iat, jti | All three are mandatory. A token without jti is rejected. |
| Lifetime | exp - iat must be > 0 and ≤ 3600 seconds. This 1-hour ceiling is not configurable. |
| JWKS | Served at AUTH_JWKS_URL. Must be HTTPS with a public hostname unless APP_ENV=local — bare IPs and localhost are rejected. |
jti becomes auth.token_id in every log line for that request, so make it unique per token if you want per-request traceability.
When the JWKS endpoint is unreachable, keyring retries 3 times with backoff and then answers 503 with Retry-After: 5 — deliberately not 401, so clients retry instead of treating the token as invalid.
Required unless you explicitly opt out. See the next section — this is the single most consequential deployment decision.
Run the collector in the same task/pod and point OTEL_COLLECTOR_URL at http://localhost:4318.
This is a correctness and latency decision, not a preference. With AUDIT_DURABILITY=required (the default), the audit event for a sensitive operation is exported synchronously, in the request path, and the response is withheld until the collector acknowledges it:
POST to <OTEL_COLLECTOR_URL>/v1/logs.429 and 5xx are retried. Everything else, including redirects, fails immediately.Pointing OTEL_COLLECTOR_URL at a vendor intake across the internet puts that vendor's latency and availability in front of every sensitive request. A loopback sidecar keeps acknowledgment local and lets the collector batch and retry egress itself.
receivers:
otlp:
protocols:
http:
endpoint: 127.0.0.1:4318
processors:
batch:
timeout: 5s
send_batch_size: 512
exporters:
otlphttp:
endpoint: https://otlp.your-vendor.example.com
headers:
api-key: ${env:VENDOR_API_KEY}
service:
pipelines: # logs is the one in the request path; traces/metrics are async
logs: { receivers: [otlp], processors: [batch], exporters: [otlphttp] }
traces: { receivers: [otlp], processors: [batch], exporters: [otlphttp] }
metrics: { receivers: [otlp], processors: [batch], exporters: [otlphttp] }
Traces, logs, and metrics also export to <base>/v1/{traces,logs,metrics} on the same base URL.
If you genuinely cannot run a collector, set AUDIT_DURABILITY=best_effort deliberately. Telemetry becomes batched and best-effort, and a sensitive response can be returned whose audit event was never persisted.
| Variable | Default | Description |
|---|---|---|
APP_ENV | — | local, staging, or production. Required. |
AUTH_AUDIENCE | — | Expected JWT aud. Required. |
AUTH_ISSUER | — | Expected JWT iss. Required. |
AUTH_JWKS_URL | — | JWKS endpoint. Required. HTTPS + public hostname unless APP_ENV=local. |
ENCRYPTION_CONFIG | — | Required. Inline JSON, or a path to a .json/.yaml/.yml file (file paths are supported on this image). |
AUTH_JWT_ALGORITHMS | RS256 | Comma-separated allowlist. Only RS/ES 256/384/512 are accepted. |
AUTH_CLOCK_TOLERANCE_SECONDS | 5 | Clock-skew allowance, 0–300. |
AUDIT_DURABILITY | required | required withholds sensitive responses until the collector acknowledges. best_effort opts out. |
OTEL_COLLECTOR_URL | — | OTLP/HTTP base URL. Required unless AUDIT_DURABILITY=best_effort. |
OTEL_COLLECTOR_HEADERS | — | Comma-separated Name=value. Percent-encode commas/equals in values. content-type is rejected. Never logged. |
LOG_LEVEL | info | debug, info, warning, error. |
HOST | 0.0.0.0 in image | 127.0.0.1, 0.0.0.0, or ::1. |
PORT | 7464 | Listen port. |
RUNTIME | node | Leave at node for containers. |
Invalid env fails every route, including health checks. Runtime env validation is global, so a bad value 500s /healthz too. On this image it is worse than that: the Node entrypoint validates at boot and, on failure, logs the error and never binds the port — the process may not exit non-zero, so your orchestrator sees a container that is up but refusing connections. Treat the absence of the Keyring API listening on ... log line as the failure signal.
All three sections are mandatory: credential, authorization_gate, secret_transfer. dek_cache is optional but you should always set it.
The example below omits origin_policy on purpose: that is the default, and it enforces the bundled manifest. Add it only to point at your own copy — see Origin allowlist for when that is necessary.
{
"credential": {
"active_adapter_id": "aws-credential",
"active_key_id": "arn:aws:kms:REGION:ACCOUNT:key/CREDENTIAL_KEY_ID",
"adapters": {
"aws-credential": {
"type": "aws",
"region": "REGION",
"credential_source": "runtime_environment",
"allowed_key_ids": ["arn:aws:kms:REGION:ACCOUNT:key/CREDENTIAL_KEY_ID"]
}
}
},
"authorization_gate": {
"active_adapter_id": "aws-gate",
"active_key_id": "arn:aws:kms:REGION:ACCOUNT:key/GATE_KEY_ID",
"adapters": {
"aws-gate": {
"type": "aws",
"region": "REGION",
"credential_source": "runtime_environment",
"allowed_key_ids": ["arn:aws:kms:REGION:ACCOUNT:key/GATE_KEY_ID"]
}
}
},
"secret_transfer": {
"active_kid": "transfer-2026-01",
"adapters": {
"aws-transfer": {
"type": "aws",
"region": "REGION",
"credential_source": "runtime_environment",
"keys": {
"transfer-2026-01": "arn:aws:kms:REGION:ACCOUNT:key/TRANSFER_KEY_ID"
}
}
}
},
"dek_cache": { "capacity": 1024, "ttl_seconds": 300 }
}
secret_transfer.keys maps kid → key reference, and active_kid selects which key is currently handed out. To rotate, add the new kid alongside the old one so in-flight sealed secrets stay decryptable, then switch active_kid.
dek_cache is opt-in. Omit the key and caching is off entirely — every single operation pays a KMS round trip. Always set it in production:
"dek_cache": { "capacity": 1024, "ttl_seconds": 300 }
If the key is present but a field is omitted, capacity defaults to 256 and ttl_seconds to 600. Size capacity to your working set of distinct DEKs; treat ttl_seconds as the maximum staleness you accept after a key rotation.
Measured on a short-lived serverless runtime it skipped ~96% of KMS unwraps (one kms:Decrypt per environment per TTL window); a hit costs 2–3 ms versus ~200 ms for a KMS-bound unwrap. Long-lived tasks share one cache and do better.
It stores non-extractable key handles in process memory only, must never be externalized (plaintext DEKs do not leave the process), and losing it to task churn costs only a re-unwrap.
credential_source: "runtime_environment" resolves AWS credentials from the platform: env vars first, then the container credentials endpoint (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI / _FULL_URI). On ECS this is the task role, delivered by the agent — nothing to configure in the container. Credentials refresh 5 minutes ahead of expiry.
EC2 IMDS and IRSA/web-identity are deliberately unsupported. Resolution happens eagerly at startup, so a task without a usable credential source fails at boot rather than on the first KMS call. The boot log line to look for is aws.credential_source: container_endpoint.
Use credential_source: "static" only outside AWS; it requires inline access_key_id/secret_access_key, which must be absent when using runtime_environment.
{
"family": "keyring",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "1024",
"memory": "2048",
"runtimePlatform": { "cpuArchitecture": "ARM64", "operatingSystemFamily": "LINUX" },
"taskRoleArn": "arn:aws:iam::ACCOUNT:role/keyring-task",
"executionRoleArn": "arn:aws:iam::ACCOUNT:role/keyring-execution",
"containerDefinitions": [
{
"name": "keyring",
"image": "composiohq/keyring@sha256:REPLACE_WITH_DIGEST",
"essential": true,
"portMappings": [{ "containerPort": 7464, "protocol": "tcp" }],
"dependsOn": [{ "containerName": "otel-collector", "condition": "START" }],
"environment": [
{ "name": "APP_ENV", "value": "production" },
{ "name": "HOST", "value": "0.0.0.0" },
{ "name": "PORT", "value": "7464" },
{ "name": "LOG_LEVEL", "value": "info" },
{ "name": "AUDIT_DURABILITY", "value": "required" },
{ "name": "OTEL_COLLECTOR_URL", "value": "http://localhost:4318" },
{ "name": "AUTH_AUDIENCE", "value": "keyring-production" },
{ "name": "AUTH_ISSUER", "value": "https://issuer.example.com" },
{ "name": "AUTH_JWKS_URL", "value": "https://issuer.example.com/.well-known/jwks.json" }
],
"secrets": [
{
"name": "ENCRYPTION_CONFIG",
"valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:keyring/encryption-config"
}
],
"healthCheck": {
"command": ["CMD", "/usr/local/bin/node", "/app/healthcheck.mjs"],
"interval": 30,
"timeout": 5,
"retries": 3,
"startPeriod": 15
}
},
{
"name": "otel-collector",
"image": "otel/opentelemetry-collector-contrib@sha256:REPLACE_WITH_DIGEST",
"essential": true,
"portMappings": [{ "containerPort": 4318, "protocol": "tcp" }],
"secrets": [
{
"name": "VENDOR_API_KEY",
"valueFrom": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:keyring/otel-vendor-key"
}
]
}
]
}
Notes on the above:
http://localhost:4318 reaches the sidecar with no service discovery.essential: true deliberately: with AUDIT_DURABILITY=required, a task whose collector died cannot serve sensitive requests and should be replaced, not left running degraded.dependsOn: START avoids a window where keyring accepts traffic before the collector listens. Use HEALTHY if your collector image defines a health check.ENCRYPTION_CONFIG goes in secrets, not environment — it names your KMS keys, and task definition revisions are readable by anyone with ecs:DescribeTaskDefinition.secretsmanager:GetSecretValue (plus kms:Decrypt on the secret's key). The task role is what carries the KMS permissions above. They are different roles; mixing them up is the most common failure here.enableExecuteCommand: false on the service. With it off, ecs:ExecuteCommand is rejected server-side, so there is no interactive path into a container holding plaintext credentials.7464, health check path /healthz. Keyring is stateless; instances are interchangeable.terminationGracePeriodSeconds above your longest expected forwarded response. Forwarded bodies stream, so a large upload or slow provider can outlive a default drain window and get cut off mid-response.Boot succeeded only if you see Keyring API listening on .... Then, in order:
curl -fsS https://keyring.internal.example.com/healthz # 200, no auth
curl -fsS https://keyring.internal.example.com/transfer-keys # 200, no auth
curl -fsS -H "Authorization: Bearer $JWT" \
https://keyring.internal.example.com/metadata
GET /transfer-keys matters more than it looks: transfer keys are validated lazily, not at boot. A wrong key spec, missing kms:GetPublicKey, or a non-OAEP key surfaces as a 500 on this route and nowhere earlier. Always hit it after a config change.
GET /metadata returns version, environment, runtime, and the authenticated issuer / token_id. A 200 proves your JWT issuer, JWKS URL, audience, and this deployment all agree end to end — it is the fastest auth smoke test you have.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz | No | Liveness |
| GET | /readyz | No | Readiness |
| GET | /transfer-keys | No | Public RSA-OAEP-256 sealing keys (public material only) |
| GET | /metadata | Yes | Version, environment, runtime, authenticated issuer/token id |
| POST | /api/v1/keys/dek | Yes | Generate a DEK envelope |
| POST | /api/v1/credential/encrypt | Yes | Encrypt a client-sealed secret into a credential envelope |
| POST | /api/v1/credential/encrypt/bulk | Yes | Bulk encrypt (1–64 items) under one DEK |
| POST | /api/v1/authorization-gate/encrypt | Yes | Wrap a credential envelope in a consent-gate envelope |
| POST | /api/v1/authorization-gate/encrypt/bulk | Yes | Bulk gate-encrypt (1–64 items) |
| POST | /api/v1/authorization-gate/decrypt | Yes | Unwrap the gate layer, returning the still-encrypted inner envelope |
| POST | /api/v1/exchange | Yes | Token exchange/refresh with a provider; secret fields re-encrypted |
| POST | /api/v1/forward/http | Yes | Forward a request, resolving secrets in-flight; streams the response |
| POST | /api/v1/rekey | Yes | Re-encrypt an envelope under a new DEK |
| GET | /openapi.json | No | OpenAPI document |
| GET | / | No | Swagger UI |
| Limit | Value |
|---|---|
| JSON request body | 1 MiB (413 on excess) |
| Forward body, passthrough | Unbounded — streams through, exempt from the JSON cap |
| Forward body, quill mode | 1 MiB |
| Forward envelope header | 32 KiB (Node header parser raised to 64 KiB to suit) |
| Exchange response | 1 MiB, 30s read timeout |
| Forward upstream response | No size cap, no read timeout — bounded by the client |
| Bulk encrypt items | 1–64 |
Outbound requests are SSRF-guarded: https: is required outside local, private/loopback/link-local/CGNAT ranges are blocked (including IPv4-mapped and NAT64-embedded forms), redirects are manual with a 10-hop cap, and authorization / cookie / proxy-authorization are stripped on cross-origin hops.
Keyring API listening on ... after a deploy — the process is up but not serving.audit.export_failure_reason / audit.export_endpoint warnings — sustained failures mean sensitive requests are failing closed.503 responses with Retry-After — your JWKS endpoint is unreachable, not a client problem.500 on /transfer-keys — transfer key misconfiguration or missing kms:GetPublicKey.Two things dominate tail latency: the upstream provider's own response time, and audit-export acknowledgment when AUDIT_DURABILITY=required. A healthy loopback collector makes the second negligible; a remote or struggling one makes it the whole story.
Start at 1 vCPU / 2 GB per task and size from your own load test: the numbers depend on payload sizes, cache hit rate, and provider latency, since forwarding holds a connection open for the life of the upstream request.
Content type
Image
Digest
sha256:f0f5b5953…
Size
54.7 MB
Last updated
9 days ago
docker pull composiohq/keyring:alpha