Storage Adapters
oidc-exchange ships with all adapters compiled into a single binary. Configuration selects which adapters are active at runtime. This page details every available adapter and its configuration.
User and session storage
Section titled “User and session storage”The [repository] section selects the primary storage backend for both user records and session (refresh token) data. Three backends are available.
DynamoDB
Section titled “DynamoDB”Single-table design optimized for the access patterns oidc-exchange uses: user lookup by ID, user lookup by external ID (provider subject), and session lookup by refresh token hash.
[repository]adapter = "dynamodb"
[repository.dynamodb]table_name = "oidc-exchange"region = "us-east-1" # optional, uses SDK default if omittedDynamoDB is the recommended backend for AWS deployments, especially Lambda-based architectures where a connection pool is impractical. The table schema is defined in schemas/dynamodb/table-design.json.
PostgreSQL
Section titled “PostgreSQL”Relational storage using sqlx with connection pooling. Suitable for teams that prefer SQL or need to query user data with ad-hoc SQL.
[repository]adapter = "postgres"
[repository.postgres]url = "postgres://user:pass@localhost:5432/oidc_exchange"max_connections = 5SQLite
Section titled “SQLite”File-based storage using sqlx. Zero external dependencies, ideal for single-server deployments or development.
[repository]adapter = "sqlite"
[repository.sqlite]path = "./data/oidc-exchange.db"Session-only storage
Section titled “Session-only storage”The optional [session_repository] section overrides the backend used for session and refresh token operations without affecting user storage. This allows you to pair a relational user store with a fast session store.
When [session_repository] is configured:
- User operations (create, get, update, delete) use the
[repository]backend - Session operations (store refresh token, lookup by hash, revoke) use the
[session_repository]backend
When [session_repository] is not configured, all operations use the [repository] backend.
Valkey / Redis
Section titled “Valkey / Redis”In-memory key-value store using the fred client. Provides sub-millisecond session lookups. Compatible with Redis, Valkey, and ElastiCache.
[session_repository]adapter = "valkey"
[session_repository.valkey]url = "redis://localhost:6379"key_prefix = "oidc:"Embedded key-value store using heed (Rust bindings for LMDB). Fast local storage without a network dependency. Suitable for single-server deployments that need session performance beyond what SQLite offers.
[session_repository]adapter = "lmdb"
[session_repository.lmdb]path = "./lmdb"max_size_mb = 64Common combinations
Section titled “Common combinations”| Deployment | User storage | Session storage | Config |
|---|---|---|---|
| AWS Lambda | DynamoDB | DynamoDB (same) | [repository] adapter = "dynamodb" |
| ECS Fargate | DynamoDB | Valkey | Add [session_repository] adapter = "valkey" |
| Linux + PostgreSQL | PostgreSQL | PostgreSQL (same) | [repository] adapter = "postgres" |
| Linux + PostgreSQL + Valkey | PostgreSQL | Valkey | Add [session_repository] adapter = "valkey" |
| Linux + SQLite | SQLite | SQLite (same) | [repository] adapter = "sqlite" |
| Linux + SQLite + LMDB | SQLite | LMDB | Add [session_repository] adapter = "lmdb" |
Key management
Section titled “Key management”The [key_manager] section controls how access token JWTs are signed.
Local key signing
Section titled “Local key signing”Load a private key from disk and sign tokens in-process. Supports Ed25519 (EdDSA) keys only; the local key manager rejects any other algorithm at startup.
[key_manager]adapter = "local"
[key_manager.local]private_key_path = "./keys/ed25519.pem"algorithm = "EdDSA" # Ed25519; the only algorithm the local key manager acceptskid = "key-1"Generate a key:
# Ed25519openssl genpkey -algorithm ed25519 -out keys/ed25519.pemLocal key management is suitable for development and single-server deployments. For production, consider KMS for automatic key protection and access control.
AWS KMS
Section titled “AWS KMS”Sign tokens using an AWS KMS asymmetric key. Both RSA (RS256/RS384/RS512 and PS256/PS384/PS512) and ECDSA (ES256/ES384/ES512) signing algorithms are supported. The private key never leaves KMS, signing is a remote API call.
[key_manager]adapter = "kms"
[key_manager.kms]key_id = "arn:aws:kms:us-east-1:123456789:key/abcd-1234"algorithm = "ES256"kid = "prod-key-1"KMS handles key rotation transparently. The service uses standard AWS SDK credential resolution (environment variables, instance profile, ECS task role, etc.).
Every signing operation errors. Selected for admin-only deployments (the admin role) that never issue access tokens and so need no signing key.
[key_manager]adapter = "noop"Audit logging
Section titled “Audit logging”The [audit] section controls where compliance and security events are sent. Every token exchange, refresh, revocation, registration denial, and user lifecycle event generates an audit record.
Events are not sent to any external system. When the audit provider is down or absent, events are always written to stdout (info and below) or stderr (error and above) as structured JSON. This fallback happens regardless of adapter.
[audit]adapter = "noop"blocking_threshold = "warning"Stdout/Stderr
Section titled “Stdout/Stderr”Audit events are emitted as structured JSON to the process output: info-and-below to stdout, error-and-above to stderr.
[audit]adapter = "stdout"blocking_threshold = "warning"Send audit events to an SQS queue. Useful for building a pipeline to S3, Iceberg, or other analytics backends via Firehose or Lambda.
[audit]adapter = "sqs"blocking_threshold = "warning"
[audit.sqs]queue_url = "https://sqs.us-east-1.amazonaws.com/123456789/audit-queue"Blocking threshold
Section titled “Blocking threshold”The blocking_threshold setting controls what happens when the audit provider fails. Audit events have syslog severity levels (RFC 5424): emergency, alert, critical, error, warning, notice, info, debug.
If the audit provider fails to emit an event and the event’s severity is at or above the configured threshold, the operation that triggered the event also fails. Events below the threshold are logged to stdout/stderr as a fallback and the operation proceeds.
For example, with blocking_threshold = "warning":
- A failed
TokenExchangeaudit (severity: notice) logs to stdout and the token exchange succeeds - A failed
RegistrationDeniedaudit (severity: warning) causes the request to fail with a 500 error
Rate limiting
Section titled “Rate limiting”The [rate_limit] section bounds per-request and failed-authentication budgets. Two adapters are available.
In-process fixed window
Section titled “In-process fixed window”A per-process fixed-window limiter over a bounded, expiry-evicted map of keys. Suitable for single-instance deployments; budgets are not shared across instances.
[rate_limit]enabled = truestore = "in_process"window = "1m"per_ip = 60per_ip_failures = 10per_subject = 10per_provider = 600max_concurrent_requests = 256max_entries = 10000Every check returns Allow. Selected when rate_limit.enabled = false.
[rate_limit]enabled = falseUser sync
Section titled “User sync”The [user_sync] section enables outbound notifications when users are created, updated, or deleted.
Webhook
Section titled “Webhook”Sends HTTP POST requests with HMAC-SHA256 signed payloads to an external URL.
[user_sync]enabled = trueadapter = "webhook"
[user_sync.webhook]url = "https://internal-api.example.com/user-events"secret = "${SYNC_WEBHOOK_SECRET}"timeout = "5s"retries = 2The webhook payload:
{ "event": "user.created", "timestamp": "2026-03-24T10:00:00Z", "data": { }}Event types: user.created, user.updated, user.deleted. User sync is non-blocking: sync failures are logged via tracing::warn! and never fail the originating request.
Verifying a delivery (receiver contract)
Section titled “Verifying a delivery (receiver contract)”Each delivery is authenticated and identified by three headers:
| Header | Value |
|---|---|
X-Webhook-Timestamp |
The RFC3339 instant the delivery was minted |
X-Webhook-Delivery-Id |
A ULID unique to the delivery occasion |
X-Signature-256 |
sha256= followed by the hex HMAC-SHA256 of <X-Webhook-Timestamp> "." <X-Webhook-Delivery-Id> "." <raw request body> under your configured secret |
The signature and delivery id are minted once per logical delivery, outside
the retry loop: every attempt in a retry burst carries the same id, timestamp,
and signature, and byte-identical bodies. A repeated X-Webhook-Delivery-Id is
therefore a retry of one delivery; treat it as such, not as an anomaly.
A conforming receiver must:
- Verify the signature before parsing the body, which makes
X-Webhook-Timestampan authenticated value. - Reject deliveries whose
X-Webhook-Timestampis outside ±5 minutes of the receiver’s clock. This bounds replay of a captured delivery to the tolerance window. - Deduplicate on
X-Webhook-Delivery-Id, retaining seen ids for at least that ±5-minute window, at least as long as timestamps are trusted, so no expired delivery can be replayed past the dedup memory. - Treat any 2xx as success. 5xx and timeout responses are retried by the sender
up to the configured
retriescount with exponential backoff; 4xx is not retried.
Worked receiver example (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDelivery(req, rawBody, secret, seenIds, now = Date.now()) { // 1. Signature first, over exactly the documented input, before any JSON.parse. const expected = "sha256=" + createHmac("sha256", secret) .update(`${req.header("x-webhook-timestamp")}.${req.header("x-webhook-delivery-id")}.${rawBody}`) .digest("hex"); const received = req.header("x-signature-256"); if ( typeof received !== "string" || received.length !== expected.length || !timingSafeEqual(Buffer.from(received), Buffer.from(expected)) ) { return { ok: false, reason: "bad signature" }; }
// 2. Freshness: reject anything outside the ±5 minute tolerance. const sentAt = Date.parse(req.header("x-webhook-timestamp")); const TOLERANCE_MS = 5 * 60 * 1000; // keep this constant paired with the dedup window below if (!Number.isFinite(sentAt) || Math.abs(now - sentAt) > TOLERANCE_MS) { return { ok: false, reason: "stale or future timestamp" }; }
// 3. Dedup: one id is one delivery; repeats are retries, not new events. const deliveryId = req.header("x-webhook-delivery-id"); if (seenIds.has(deliveryId)) { return { ok: true, reason: "retry of a delivered id (acknowledged, not reprocessed)" }; } seenIds.add(deliveryId); // retain ids for AT LEAST the tolerance window
// 4. Only now parse and act on the body. const event = JSON.parse(rawBody); return { ok: true, event };}Release note (breaking receiver change)
Section titled “Release note (breaking receiver change)”Webhook receivers must be updated when deploying this version. Both the
signed input and the X-Signature-256 value format changed:
- Before:
X-Signature-256carried the bare hex HMAC-SHA256 of the raw body only. - After: the header carries
sha256=<hex>overtimestamp.delivery-id.body, and receivers must additionally checkX-Webhook-Timestampfreshness (±5 minutes) and deduplicate onX-Webhook-Delivery-Id.
Every existing receiver rejects every delivery until it is updated; there is no
negotiation or compatibility mode. The failure is quiet on the receiver side (a
4xx is not retried, and sync failures are logged-and-swallowed upstream), so plan
the receiver deploy together with this upgrade. user_sync.enabled defaults to
false and no shipped example enables it; see the worked example above for the
reference verification flow.
Release note (embedding surface, crates/adapters)
Section titled “Release note (embedding surface, crates/adapters)”For embedders linking crates/adapters directly (the IdentityProvider trait
signature itself is unchanged):
JwksCache::new/with_ttlgained a required admitted-algorithms parameter (e.g. pass the same constant your validator advertises). Constructing without it no longer compiles.JwksCache::get_keysreturnsArc<VerificationKeySet>, notserde_json::Value. Useget_key(kid)for the resolve → one rate-limited forced refetch → re-resolve → fail-closed path both built-in providers use.- Key-selection behavior changed to one shared constructor
(
VerificationKeySet::from_jwks): keys declaring an unknown algorithm (e.g.RSA-OAEP) are rejected instead of being inferred from their key type; alg-less RSA / EC P-256 / OKP Ed25519 signing keys are now accepted on Apple’s path too; and a duplicate-kidJWKS whose eligible entry appears second now validates (two eligible entries under onekidremain an error). - Discovery endpoint origins: each provider’s discovery document may name
only origins pinned at config load (
endpoint_origins, plus the issuer’s own). The check currently ships in warning mode (undeclared origins log a warning and are served), and rejecting them (WarntoEnforce) is a separate future release-owner decision after one release of that telemetry, not part of this version. - Every outbound provider request goes through
ProviderTransport(status read before body; bodies bounded at the shared 64 KiB ceiling); webhook delivery keeps its own operator-timeout client by design.
Disables user sync. This is the default when user_sync.enabled is false or the section is omitted.