Vouch Server Operator Guide
This is the handbook for running your own Vouch server. It covers installing the server, configuring it, connecting it to your identity provider, administering your organization, and operating it in production — across cloud, on-premise, and air-gapped deployments.
What this guide is not
Vouch is three separate things, and only one of them is documented here.
| What it is | Where it’s documented | |
|---|---|---|
This guide (docs.vouch.sh) | The server you install and run yourself: deployment, configuration, administration, operations. | You are here |
Vouch CLI and integrations (vouch.sh/docs) | Installing the vouch CLI, enrolling a hardware key, and the credential helpers — SSH, AWS, EKS, Kubernetes, GitHub, Docker and the rest. Also the OIDC provider reference: endpoints, tokens, grant types, claims. | vouch.sh/docs |
The hosted Vouch service (us.vouch.sh) | A managed multi-tenant deployment operated by Vouch. You do not install or operate it. | vouch.sh |
If you are looking for how to run vouch enroll, wire up credential_process for the AWS CLI, or
configure kubectl — those are client-side tasks documented at
vouch.sh/docs, not here.
If you are evaluating the hosted service rather than self-hosting, most of this guide will not apply. Capabilities that exist only on the hosted service — per-organization issuer subdomains, for example — are noted where they intersect with something you can configure, but are not documented in depth.
What Vouch Server does
Vouch Server is the backend that makes hardware-backed authentication work. The core principle is that no credential is issued without proof of human presence at a hardware authenticator.
- OIDC Provider — issues DPoP-bound access tokens after FIDO2 verification
- SSH Certificate Authority — signs short-lived Ed25519 certificates
- Credential Broker — exchanges access tokens for AWS STS credentials and GitHub tokens
- SCIM Endpoint — receives user provisioning and de-provisioning from your IdP
- WebAuthn Relying Party — manages FIDO2 credential registration and assertion
It sits behind the identity provider you already run rather than replacing it. Users prove who they are to Google Workspace, Entra ID, Okta, or any OIDC/SAML provider; Vouch binds that verified identity to a hardware key and issues short-lived credentials from it.
What you will need
At minimum, to get a server running:
- A domain name, and a TLS certificate for it
- A database — SQLite for a single node, PostgreSQL for more than one
- At least one upstream identity provider; the server refuses to start without one
- A JWT secret of at least 32 characters, or an AWS KMS HMAC key
Where to start
- New to Vouch Server — Quick Start takes you from nothing to a working enrollment.
- Planning a production deployment — Deployment Overview for sizing and architecture, then Configuration Sources.
- Already running, something is wrong — Troubleshooting.
Getting help
- GitHub Issues — bug reports
- GitHub Discussions — questions
Quick Start
This walks through getting a Vouch server running and proving it works, end to end: start the server, enroll the first user, log in, and issue a credential. It uses SQLite and no TLS, so it is a development setup — not a production deployment. Deployment Overview covers what changes for production.
Budget about 20 minutes. You will need a YubiKey.
Before you start
You need an upstream identity provider. The server refuses to start without one, and this is the step that takes longest, so do it first. Any OIDC provider works; Google Workspace is the quickest if you already have it.
Register an OAuth client with your IdP and set the redirect URI to:
http://localhost:3000/oauth/callback
Keep the client ID and secret. See Identity Providers for provider-specific instructions.
1. Configure
# Where users reach this server. WebAuthn credentials bind to this value.
export VOUCH_RP_ID=localhost
export VOUCH_LISTEN_ADDR=0.0.0.0:3000
# A local database file
export VOUCH_DATABASE_URL="sqlite:vouch-dev.db?mode=rwc"
# Signs internal state tokens. Minimum 32 characters.
export VOUCH_JWT_SECRET="$(openssl rand -base64 48)"
# Your upstream IdP. "google" here is a slug you choose.
export VOUCH_IDPS=google
export VOUCH_IDP_GOOGLE_TYPE=oidc
export VOUCH_IDP_GOOGLE_ISSUER=https://accounts.google.com
export VOUCH_IDP_GOOGLE_CLIENT_ID=<your-client-id>
export VOUCH_IDP_GOOGLE_CLIENT_SECRET=<your-client-secret>
# Restrict who may enroll. Without this, anyone your IdP authenticates can.
export VOUCH_ALLOWED_DOMAINS=example.com
Two of these deserve a second look before you go further:
VOUCH_RP_IDis baked into every WebAuthn credential. Changing it later invalidates every enrolled key.localhostis correct for this walkthrough and wrong for anything else.VOUCH_ALLOWED_DOMAINS, if unset, means open enrollment — any domain your IdP will authenticate. The startup log says(open enrollment)when that is the case.
2. Start the server
vouch-server
3. Read the startup log
The startup log is the real health check. It reports what the server actually loaded, which is usually where a misconfiguration shows up. Confirm these lines:
Configuration source: environment variables
Database migrations up to date (N total)
SSH CA initialized: ssh-ed25519 AAAA... vouch-ca@localhost
IdP 'google' (oidc): brand=Google, issuer=https://accounts.google.com, ..., enrollment_domains=example.com
Document encryption: plaintext (no document key configured)
Database pool: max_connections=25, ...
Sessions: duration=8h, dpop_max_age=300s, ...
CORS: same-origin only
Watch for these warnings. All three are fine here and none are fine in production:
| Warning | Meaning |
|---|---|
Using ephemeral OIDC signing key | Tokens die on restart, and fail across instances. Set VOUCH_OIDC_SIGNING_KEY. |
Using ephemeral OIDC RSA signing key | Same, for AWS credential tokens. Set VOUCH_OIDC_RSA_SIGNING_KEY. |
Generating new SSH CA keypair at ./ssh_ca_key | No CA key existed, so one was created. Fine now; on a fresh volume in production it silently replaces your CA. |
If the server exited instead, the error message names the problem directly — a missing IdP variable, a short JWT secret, an unreachable issuer. See Troubleshooting.
4. Verify it is serving
# Liveness — returns the plain string "ok", not JSON
curl -s http://localhost:3000/health
# Readiness — checks the database
curl -s http://localhost:3000/health/ready
# {"status":"ready"}
# The OIDC provider is up
curl -s http://localhost:3000/.well-known/openid-configuration | jq .issuer
# Signing keys are published
curl -s http://localhost:3000/oauth/jwks | jq '.keys[].alg'
# "ES256"
# "RS256"
# The SSH CA is loaded
curl -s http://localhost:3000/v1/credentials/ssh/ca
# {"public_key":"ssh-ed25519 AAAA...","comment":"vouch-ca@localhost"}
5. Enroll the first user
This step decides who administers your organization, so do it deliberately.
Vouch has no organizations and no administrators until somebody enrolls. On first enrollment the server creates an organization from the user’s email domain and makes that first user its administrator. Whoever enrolls first holds the only admin account.
Enroll from a workstation with the CLI installed:
vouch --server http://localhost:3000 enroll
A browser opens; sign in with your IdP, then touch your YubiKey when prompted. (For CLI installation, see vouch.sh/docs.)
You can also enroll entirely in the browser at http://localhost:3000/enroll/start.
6. Log in and get a credential
# FIDO2 login — touch the YubiKey
vouch --server http://localhost:3000 login
# Issue an SSH certificate
vouch --server http://localhost:3000 credential ssh
7. Confirm it was recorded
Open http://localhost:3000/admin in a browser, signed in as the user you just enrolled.
- Members lists your user, marked as an administrator.
- Audit shows
enrollment, thenlogin_success, thenssh_credential.
If /admin returns 403, you are signed in as a user who is not an administrator — that means
somebody else enrolled first.
What to do next
You now have a working server. For production, the differences that matter most:
- TLS, Ports, and mTLS — real certificates. The server moves to
ports 443 and 80, and
VOUCH_LISTEN_ADDRstops applying. - Signing Keys — replace both ephemeral OIDC keys with durable ones, and provision the SSH CA key explicitly instead of letting it auto-generate.
- Database — PostgreSQL if you will run more than one instance.
- Behind a Reverse Proxy — if anything sits in front,
either preserve the client IP (TCP passthrough) or set
VOUCH_TRUSTED_PROXIES(TLS terminated at the proxy), or rate limiting will key on your load balancer. - Security Hardening — the pre-production checklist.
- Monitoring and Metrics — probes, metrics, and log format.
Deployment Overview
This section covers deploying the Vouch server for your organization. The server is the central authentication backend that handles FIDO2 verification, session management, SSH certificate signing, and OIDC token issuance.
Deployment Checklist
Before deploying, you need:
- Domain name — A domain for your Vouch server (e.g.,
auth.example.com) - TLS certificate — Valid certificate for your domain (or use Let’s Encrypt)
- Database — SQLite (single node) or PostgreSQL (multi-node)
- Identity provider — At least one upstream OIDC or SAML IdP (Google Workspace, Entra ID, Okta, or any compliant provider)
- JWT secret — Cryptographically random string, minimum 32 characters (or use AWS KMS HMAC)
- SSH CA key (optional) — Ed25519 key pair for signing SSH certificates (or use AWS KMS)
- OIDC signing key (optional) — P-256 EC key for signing ID tokens (or use AWS KMS)
Architecture
Internet
│
▼
┌─────────────────┐
│ Load Balancer │
│ TCP passthrough │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Vouch Server │
│ │
│ • Auth Portal │
│ • OIDC Provider│
│ • SSH CA │
│ • REST API │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Database │
│ │
│ SQLite or │
│ PostgreSQL │
└─────────────────┘
Deployment Methods
| Method | Best for |
|---|---|
| Systemd | Bare metal, VMs, single-node |
| Docker | Container-based deployments |
| Kubernetes | Multi-node, high availability |
Configuration
All configuration is via environment variables. See the Configuration Reference for the full list.
The minimum configuration requires:
VOUCH_RP_ID=auth.example.com # Your domain
VOUCH_JWT_SECRET=<64-char-secret> # Session signing secret
VOUCH_DATABASE_URL=sqlite:vouch.db?mode=rwc # Database
For production, also set:
VOUCH_TLS_CERT=<base64-encoded-pem> # TLS certificate
VOUCH_TLS_KEY=<base64-encoded-pem> # TLS private key
VOUCH_SSH_CA_KEY=<base64-encoded-pem> # SSH CA key (or VOUCH_SSH_CA_KMS_KEY_ID)
VOUCH_IDPS=google # External IdP(s)
VOUCH_IDP_GOOGLE_TYPE=oidc
VOUCH_IDP_GOOGLE_ISSUER=https://accounts.google.com
VOUCH_IDP_GOOGLE_CLIENT_ID=...
VOUCH_IDP_GOOGLE_CLIENT_SECRET=...
AWS deployments can use KMS for all signing operations instead of managing local keys. See the Configuration Reference for KMS options.
Sizing
| Component | Minimum | Recommended |
|---|---|---|
| CPU | 1 vCPU | 2 vCPU |
| Memory | 256 MB | 512 MB |
| Disk | 1 GB (SQLite) | 10 GB (PostgreSQL) |
The server is single-process, async (tokio). Per-session memory overhead is ~2 KB of token metadata. The primary bottleneck is database I/O during token issuance and session validation.
Database guidance:
- SQLite — single-node deployments under ~500 users
- PostgreSQL — multi-node deployments, or more than 500 users
- Aurora DSQL — AWS deployments using managed database infrastructure
Next Steps
- Database Setup — Choose and configure your database
- TLS Configuration — Set up HTTPS
- Configuration Reference — Full environment variable reference
- Identity Provider Setup — Connect your corporate IdP
Systemd (Bare Metal)
Deploy Vouch as a systemd service on bare metal servers or VMs.
Install via Package
The RPM and DEB packages include a systemd service unit:
# RPM (RHEL/Fedora/Amazon Linux)
rpm -ivh vouch-server-<version>-1.x86_64.rpm
# DEB (Debian/Ubuntu)
dpkg -i vouch-server_<version>_amd64.deb
The package installs:
- Binary at
/usr/bin/vouch-server - Systemd unit at
/etc/systemd/system/vouch-server.service - Default config at
/etc/vouch/vouch.env - Data directory at
/data(owned by thevouchuser, mode700)
Configure
Edit the environment file:
sudo cp /etc/vouch/vouch.env /etc/vouch/vouch.env.local
sudo chmod 600 /etc/vouch/vouch.env.local
sudo vi /etc/vouch/vouch.env.local
At minimum, set:
VOUCH_RP_ID=auth.example.com
VOUCH_JWT_SECRET=<your-64-character-secret>
VOUCH_DATABASE_URL=sqlite:/data/vouch.db?mode=rwc
VOUCH_TLS_CERT=<base64-encoded-certificate>
VOUCH_TLS_KEY=<base64-encoded-private-key>
See Configuration Reference for all options.
Start the Service
# Enable and start
sudo systemctl enable --now vouch-server
# Check status
sudo systemctl status vouch-server
# View logs
sudo journalctl -u vouch-server -f
Manual Install (Without Package)
If installing the binary manually:
-
Copy the binary:
sudo cp vouch-server /usr/bin/ sudo chmod 755 /usr/bin/vouch-server -
Create a systemd unit:
# /etc/systemd/system/vouch-server.service [Unit] Description=Vouch Identity Server After=network.target [Service] Type=simple User=vouch Group=vouch EnvironmentFile=/etc/vouch/vouch.env ExecStart=/usr/bin/vouch-server Restart=on-failure RestartSec=5 # Security hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/data AmbientCapabilities=CAP_NET_BIND_SERVICE [Install] WantedBy=multi-user.target -
Create the service user and directories:
sudo useradd -r -s /sbin/nologin vouch sudo mkdir -p /etc/vouch /data sudo chown vouch:vouch /data sudo chmod 700 /data -
Reload and start:
sudo systemctl daemon-reload sudo systemctl enable --now vouch-server
Upgrading
# Back up database
sudo cp /data/vouch.db /data/vouch.db.backup.$(date +%Y%m%d)
# Upgrade package (migrations run automatically on next startup)
sudo rpm -Uvh vouch-server-<new-version>-1.x86_64.rpm
# or: sudo dpkg -i vouch-server_<new-version>_amd64.deb
# Restart
sudo systemctl restart vouch-server
# Verify
curl -k https://auth.example.com/health
Docker
Deploy Vouch using Docker or Docker Compose.
Docker Run
docker run -d \
--name vouch-server \
--restart unless-stopped \
-p 443:443 \
-v vouch-data:/data \
-e VOUCH_RP_ID=auth.example.com \
-e VOUCH_JWT_SECRET=<your-64-character-secret> \
-e VOUCH_DATABASE_URL=sqlite:/data/vouch.db?mode=rwc \
-e VOUCH_TLS_CERT=<base64-encoded-certificate> \
-e VOUCH_TLS_KEY=<base64-encoded-private-key> \
ghcr.io/vouch-sh/vouch:latest
Docker Compose
# docker-compose.yml
services:
vouch-server:
image: ghcr.io/vouch-sh/vouch:latest
container_name: vouch-server
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- vouch-data:/data
env_file:
- vouch.env
environment:
VOUCH_DATABASE_URL: sqlite:/data/vouch.db?mode=rwc
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "--no-check-certificate", "https://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
vouch-data:
Create a vouch.env file:
VOUCH_RP_ID=auth.example.com
VOUCH_JWT_SECRET=<your-64-character-secret>
VOUCH_TLS_CERT=<base64-encoded-certificate>
VOUCH_TLS_KEY=<base64-encoded-private-key>
VOUCH_SSH_CA_KEY=<base64-encoded-ssh-ca-key>
Start:
docker compose up -d
docker compose logs -f vouch-server
With PostgreSQL
# docker-compose.yml
services:
vouch-server:
image: ghcr.io/vouch-sh/vouch:latest
container_name: vouch-server
restart: unless-stopped
ports:
- "443:443"
- "80:80"
env_file:
- vouch.env
environment:
VOUCH_DATABASE_URL: postgres://vouch:password@postgres:5432/vouch
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "--no-check-certificate", "https://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
postgres:
image: postgres:16
container_name: vouch-postgres
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: vouch
POSTGRES_USER: vouch
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vouch"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:
Air-Gapped Docker
For air-gapped environments, load the image from a saved archive:
# On connected machine
docker pull ghcr.io/vouch-sh/vouch:<version>
docker save ghcr.io/vouch-sh/vouch:<version> -o vouch-server-<version>.tar
# Transfer to air-gapped environment
# Load image
docker load < vouch-server-<version>.tar
Upgrading
# Pull new image
docker compose pull
# Restart with new image
docker compose up -d
# Verify
docker compose logs -f vouch-server
curl -k https://auth.example.com/health
Kubernetes (Helm)
Deploy Vouch on Kubernetes using the Helm chart.
Prerequisites
- Kubernetes cluster (1.24+)
- Helm 3
- A persistent volume provisioner (for SQLite) or external PostgreSQL
Install
# Install from OCI registry
helm install vouch-server oci://ghcr.io/vouch-sh/charts/vouch-server \
--version 0.1.0 \
--namespace vouch \
--create-namespace \
--values my-values.yaml
Values
Key values to configure:
# values.yaml
image:
repository: ghcr.io/vouch-sh/vouch
pullPolicy: IfNotPresent
tag: "" # defaults to chart appVersion
serviceAccount:
create: true
annotations: {}
podSecurityContext:
fsGroup: 65532
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
service:
type: ClusterIP
port: 3000
# Environment variables for vouch-server
env:
VOUCH_LISTEN_ADDR: "0.0.0.0:3000"
VOUCH_DATABASE_URL: "sqlite:/data/vouch.db?mode=rwc"
VOUCH_RP_ID: "auth.example.com"
VOUCH_BASE_URL: "https://auth.example.com"
RUST_LOG: "info,vouch_server=debug"
# Secret environment variables
# Reference an existing secret containing keys like:
# - VOUCH_JWT_SECRET
# - VOUCH_IDPS (e.g., "google")
# - VOUCH_IDP_GOOGLE_TYPE (oidc|saml)
# - VOUCH_IDP_GOOGLE_ISSUER
# - VOUCH_IDP_GOOGLE_CLIENT_ID
# - VOUCH_IDP_GOOGLE_CLIENT_SECRET
existingSecret: ""
# Or create a new secret (puts secret values in this file; use existingSecret in production)
secrets: {}
# VOUCH_JWT_SECRET: ""
# Ingress
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: auth.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: vouch-tls
hosts:
- auth.example.com
# Resources
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Persistence (for SQLite)
persistence:
enabled: true
existingClaim: ""
storageClass: ""
accessMode: ReadWriteOnce
size: 1Gi
mountPath: /data
# Health check configuration
# Liveness uses /health (static, always 200 while the process is up).
# Readiness uses /health/ready (checks the database, 503 when it is unreachable).
healthcheck:
path: /health
readinessPath: /health/ready
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
Using Kubernetes Secrets
Create secrets for sensitive values:
kubectl create secret generic vouch-secrets \
--namespace vouch \
--from-literal=VOUCH_JWT_SECRET='<your-64-character-secret>' \
--from-literal=VOUCH_IDPS='google' \
--from-literal=VOUCH_IDP_GOOGLE_TYPE='oidc' \
--from-literal=VOUCH_IDP_GOOGLE_ISSUER='https://accounts.google.com' \
--from-literal=VOUCH_IDP_GOOGLE_CLIENT_ID='...' \
--from-literal=VOUCH_IDP_GOOGLE_CLIENT_SECRET='...'
Then reference in values:
existingSecret: vouch-secrets
Air-Gapped Kubernetes
For air-gapped environments:
-
Save and transfer the chart:
helm pull oci://ghcr.io/vouch-sh/charts/vouch-server --version 0.1.0 # Transfer vouch-server-0.1.0.tgz to air-gapped environment -
Save and transfer the container image:
docker pull ghcr.io/vouch-sh/vouch:0.1.0 docker save ghcr.io/vouch-sh/vouch:0.1.0 -o vouch-0.1.0.tar # Transfer and load into your private registry -
Install from the local chart:
helm install vouch-server ./vouch-server-0.1.0.tgz \ --namespace vouch \ --create-namespace \ --set image.repository=registry.internal/vouch \ --values my-values.yaml
Upgrading
helm upgrade vouch-server oci://ghcr.io/vouch-sh/charts/vouch-server \
--version <new-version> \
--namespace vouch \
--values my-values.yaml
Health Checks
Vouch exposes two separate endpoints, and they are not interchangeable:
| Probe | Endpoint | Behavior |
|---|---|---|
| Liveness | /health | Returns 200 with the body ok whenever the process is running. It performs no dependency checks, so it only ever fails if the process is hung or dead — which is exactly what a liveness probe should test. |
| Readiness | /health/ready | Runs SELECT 1 against the database. Returns 200 {"status":"ready"}, or 503 {"status":"not_ready","reason":"database"} when the database is unreachable. |
Important: point the readiness probe at
/health/ready, not/health. A pod whose database connection has failed will keep passing a/healthreadiness probe and stay in the Service’s endpoint list, sending every request to an instance that cannot serve it.
See Monitoring and Metrics for the full endpoint list and the Prometheus metrics.
Configuration Sources
The Vouch server reads its configuration from three places. This chapter covers how they combine, how S3-based configuration works, and which settings can change without a restart.
For the complete list of settings, see Environment Variables and the S3 Configuration Schema.
Precedence
- S3 configuration (highest) — a JSON object fetched from S3 at startup
- Command-line arguments —
--kebab-caseflags passed tovouch-server - Environment variables —
VOUCH_*prefixed
Every setting is available in all three forms. The command-line flags and environment variables
are the same options: each flag declares an environment variable as its fallback, so passing
--session-hours 4 overrides VOUCH_SESSION_HOURS=8. S3 configuration is applied last and
overwrites whatever the flags and environment produced.
A .env file in the working directory is loaded before parsing, so values in it behave exactly
like environment variables.
Note: S3 overriding the environment is the opposite of what most tools do, and it is deliberate — it lets a fleet share one authoritative document while the environment supplies only per-instance values. If a setting is not taking effect, check whether the S3 document is also setting it.
S3-Based Configuration
For production deployments, Vouch supports loading configuration from an S3 object. This enables:
- Centralized management — Single source of truth for multi-instance deployments
- Dynamic updates — Configuration changes without server restart (for supported fields)
- TLS hot-reload — Automatic certificate rotation without downtime
- Secrets management — S3 encryption and IAM protect the document’s secrets
Enabling S3 Configuration:
# Required: bucket name
VOUCH_S3_CONFIG_BUCKET=my-bucket
# Optional: object key (default: config/vouch-server.json)
VOUCH_S3_CONFIG_KEY=config/vouch-server.json
# Optional: AWS region (uses default credential chain region if not set)
VOUCH_S3_CONFIG_REGION=us-west-2
# Optional: polling interval in seconds (default: 60)
VOUCH_S3_CONFIG_POLL_INTERVAL=60
The document is a JSON object; see the S3 Configuration Schema for every field, its type, and its default. All certificate and key fields are base64-encoded PEM:
# Encode a PEM file for the S3 config
base64 -i cert.pem | tr -d '\n'
Bucket requirements
The configuration document contains the JWT secret, IdP client secrets, and private keys. Treat the bucket accordingly:
| Requirement | Why |
|---|---|
| Server-side encryption | The document holds secrets at rest |
| Block Public Access | It must never be reachable anonymously |
| Least-privilege IAM | The server needs only s3:GetObject and s3:HeadObject |
| Versioning | Gives you rollback and a change trail |
| Access logging | Lets you detect unauthorized reads |
A minimal bucket policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::ACCOUNT:role/vouch-server"},
"Action": ["s3:GetObject", "s3:HeadObject"],
"Resource": "arn:aws:s3:::my-bucket/config/vouch-server.json"
}
]
}
Polling behavior
- ETag-based — a
HEADrequest checks for a change before any fullGET. - Fail-fast at startup — if S3 configuration is enabled and the object cannot be fetched or parsed, the server refuses to start.
- Fail-open at runtime — if S3 becomes unreachable later, the server keeps running with the configuration it already has.
- No stale writes — configuration is only replaced after a successful fetch and parse.
AWS KMS Signing Keys
As an alternative to managing local key material, Vouch supports AWS KMS for signing operations:
| Environment Variable | Key Type | Replaces |
|---|---|---|
VOUCH_SSH_CA_KMS_KEY_ID | Ed25519 (ECC_EDWARDS_CURVE_25519) | VOUCH_SSH_CA_KEY / VOUCH_SSH_CA_KEY_PATH |
VOUCH_OIDC_SIGNING_KMS_KEY_ID | P-256 (ECC_NIST_P256) | VOUCH_OIDC_SIGNING_KEY |
VOUCH_OIDC_RSA_SIGNING_KMS_KEY_ID | RSA-3072 (RSA_3072) | VOUCH_OIDC_RSA_SIGNING_KEY |
VOUCH_JWT_HMAC_KMS_KEY_ID | HMAC-256 (HMAC_256) | VOUCH_JWT_SECRET |
Use multi-region keys (mrk- prefix) for high availability. KMS key IDs can also be set in the S3 config (ssh_ca_kms_key_id, oidc_signing_kms_key_id, jwt_hmac_kms_key_id).
See Key Management for generation and rotation details.
Hot-Reloadable vs Startup-Only Fields
| Field | Hot-Reloadable | Notes |
|---|---|---|
tls.cert, tls.key | Yes | Automatic reload on change |
| All other fields | No | Requires server restart |
Non-hot-reloadable fields include: jwt_secret, database_url, listen_addr, rp_id, rp_name, session_hours, cors_origins, allowed_domains, dpop.*, OIDC settings, SAML settings, GitHub App settings, SSH CA key, OIDC signing keys, and all KMS key IDs.
Changes to non-hot-reloadable fields in S3 are silently ignored; restart the server to apply them.
TLS Certificate Hot-Reload
Vouch supports automatic TLS certificate reloading without dropping connections:
- Via S3 polling — Update
tls.certandtls.keyin S3 config; server detects change via ETag and reloads - Via SIGHUP — Send
SIGHUPto the server process to reload TLS certificates
# Manual TLS certificate reload (Unix only)
kill -SIGHUP $(pgrep vouch-server)
Note: SIGHUP only reloads TLS certificates. It does not reload any other configuration fields.
Database Setup
Vouch supports three database backends.
SQLite (Default)
Best for single-node deployments and development. No external dependencies.
VOUCH_DATABASE_URL=sqlite:vouch.db?mode=rwc
The database file is created automatically on first startup. Migrations run automatically.
Recommendations:
- Store the database on a persistent volume
- Set restrictive file permissions:
chmod 700 /data - Back up the file regularly (it’s a single file)
# Create data directory
mkdir -p /data
chmod 700 /data
# Configure
export VOUCH_DATABASE_URL="sqlite:/data/vouch.db?mode=rwc"
PostgreSQL
Best for multi-node deployments, high availability, and production environments.
VOUCH_DATABASE_URL=postgres://user:password@db.example.com:5432/vouch
Setup:
-
Create a PostgreSQL database:
CREATE DATABASE vouch; CREATE USER vouch WITH PASSWORD 'secure-password'; GRANT ALL PRIVILEGES ON DATABASE vouch TO vouch; -
Configure the connection:
export VOUCH_DATABASE_URL="postgres://vouch:secure-password@db.example.com:5432/vouch" -
Migrations run automatically on server startup.
Recommendations:
- Use SSL for database connections in production
- Configure connection pooling at the database level
- Set up automated backups
Aurora DSQL
For AWS deployments requiring serverless, distributed SQL with strong consistency.
Aurora DSQL endpoints are auto-detected when the DATABASE_URL hostname contains .dsql. and ends with .on.aws. IAM authentication tokens are generated automatically.
VOUCH_DATABASE_URL=postgres://admin@abcdef123456.dsql.us-east-1.on.aws:5432/vouch
Multi-region configuration uses a dsql_endpoints map in the S3 configuration JSON, resolved via AWS_AZ or AWS_REGION environment variables.
Migrations
Database migrations are embedded in the server binary and run automatically on startup. There is no manual migration step required.
- SQLite migrations:
crates/vouch-server/migrations/sqlite/ - PostgreSQL migrations:
crates/vouch-server/migrations/postgres/
Backup
| Database | Backup Method | Frequency |
|---|---|---|
| SQLite | File copy (cp vouch.db vouch.db.backup) | Daily |
| PostgreSQL | pg_dump | Daily |
| Aurora DSQL | AWS automated backups | Continuous |
Back up before upgrading the Vouch server: migrations modify the schema, and restoring a backup is the only rollback.
TLS Configuration
Vouch requires HTTPS in production. TLS can be configured directly on the Vouch server or terminated at a load balancer.
Direct TLS (Recommended)
When TLS is configured, the server automatically:
- Listens on port 443 (HTTPS)
- Runs an HTTP redirect server on port 80 (308 redirect to HTTPS)
- Makes the
/healthendpoint accessible on HTTP (for load balancer health checks) - Validates the
Hostheader againstrp_idto prevent injection attacks - Ignores
VOUCH_LISTEN_ADDR(ports are fixed at 443/80)
Note: Binding to ports 80 and 443 requires
CAP_NET_BIND_SERVICEcapability on Linux. The RPM/DEB packages configure this automatically.
Configuration
Provide base64-encoded PEM certificates via environment variables:
# Encode your certificate and key
export VOUCH_TLS_CERT="$(base64 -i cert.pem | tr -d '\n')"
export VOUCH_TLS_KEY="$(base64 -i key.pem | tr -d '\n')"
Both VOUCH_TLS_CERT and VOUCH_TLS_KEY must be set together. If only one is set, the server fails to start.
TLS Properties
- Protocol: TLS 1.3 and TLS 1.2
- Implementation: rustls (no OpenSSL)
- Ciphers: BCP 195 (RFC 9325) suites only — TLS 1.3 AEAD suites, and ECDHE+AEAD suites for TLS 1.2 (AES-GCM, ChaCha20-Poly1305)
Post-Quantum Key Exchange
Both TLS listeners (HTTPS and mTLS) and all outbound TLS clients (CLI, agent, and server-to-IdP/AWS connections) prefer the X25519MLKEM768 hybrid post-quantum key-exchange group. When the peer supports it — modern browsers, Cloudflare, and AWS endpoints do — the TLS session keys are protected against “harvest now, decrypt later” attacks. Peers without ML-KEM support negotiate classical X25519 or P-256 as usual; no configuration is required on either side.
To confirm a connection actually negotiated it, from a client with OpenSSL 3.5 or later:
openssl s_client -connect auth.example.com:443 -groups X25519MLKEM768 </dev/null 2>/dev/null \
| grep -i 'negotiated group'
Browsers show the negotiated key-exchange group in their developer-tools security panel.
For Vouch’s overall post-quantum posture — which surfaces are still classical, why, and what is being tracked — see the security documentation at vouch.sh/docs/security.
Certificate Hot-Reload
Vouch supports automatic TLS certificate reloading without dropping connections. This is useful for certificate rotation (e.g., Let’s Encrypt renewals).
Via S3 Configuration
If using S3 configuration storage, update the tls.cert and tls.key fields in the S3 config file. The server detects changes via ETag polling and reloads automatically.
Via SIGHUP
Send SIGHUP to the server process to reload TLS certificates:
kill -SIGHUP $(pgrep vouch-server)
Note: SIGHUP only reloads TLS certificates. It does not reload any other configuration.
Self-Signed Certificates (Development)
For development or testing:
# Generate self-signed EC certificate
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout tls_key.pem -out tls_cert.pem -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# Base64 encode for Vouch
export VOUCH_TLS_CERT="$(base64 -i tls_cert.pem | tr -d '\n')"
export VOUCH_TLS_KEY="$(base64 -i tls_key.pem | tr -d '\n')"
Behind a Reverse Proxy
Most deployments put something in front of the Vouch server: an AWS Network Load Balancer, nginx, HAProxy, a Kubernetes ingress controller. This chapter covers what has to be configured for that to work correctly, and the one setting whose absence degrades security silently.
Terminate TLS in Vouch where you can
The recommended topology is TCP passthrough with TLS terminated inside Vouch, not TLS terminated at the proxy.
Vouch pins a BCP 195 cipher suite list, prefers hybrid post-quantum key exchange, and hosts the mTLS listener used for certificate-bound tokens. Terminating at the proxy replaces all of that with whatever the proxy negotiates, and breaks RFC 8705 certificate-bound tokens outright, because the client certificate never reaches Vouch.
Terminate at the proxy only when something else forces it — a corporate WAF requirement, or a managed load balancer that cannot pass TCP through, such as an AWS Application Load Balancer. If you do, everything below still applies.
Trusted proxies
This is the setting to get right.
VOUCH_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
VOUCH_TRUSTED_PROXIES is a comma-separated list of CIDR ranges holding your proxies. It controls
how Vouch decides a request’s client IP, which in turn drives rate limiting and the client IP
recorded on audit events.
When it is unset (the default), X-Forwarded-For is ignored completely and the TCP peer
address is used as the client IP. Behind a proxy, that peer address is the proxy. Every user
therefore shares a single rate-limit bucket, and every audit event records the load balancer’s
address instead of the user’s.
Nothing warns you about this. The server starts cleanly, requests succeed, and the only symptom is that a moderately busy deployment starts returning 429s to everyone at once, with audit records that cannot attribute anything to anyone.
When it is set, Vouch walks X-Forwarded-For from right to left and takes the first address
that is not in the trusted set — the RFC 7239 rightmost-trusted algorithm. This is the only
approach resistant to a client spoofing extra X-Forwarded-For entries: injected values sit to the
left of the addresses your own proxies appended, so the walk stops before reaching them.
The behavior in full:
| Situation | Client IP used |
|---|---|
| No trusted proxies configured | TCP peer address; X-Forwarded-For ignored |
| Peer is not in the trusted set | TCP peer address; X-Forwarded-For ignored (fail closed) |
| Peer is trusted, header present | First right-to-left X-Forwarded-For entry outside the trusted set |
| Peer is trusted, header absent or empty | TCP peer address |
| Peer is trusted, every entry trusted | TCP peer address |
| Peer is trusted, an entry is unparseable | The walk stops there and the peer address is used |
List only the ranges your proxies actually occupy. Trusting an over-broad range lets anything inside it forge a client IP. An invalid CIDR is a fatal startup error, not a warning.
Verify it by checking that audit events at /admin/audit show real client addresses rather than
your load balancer’s.
Host header validation
Vouch validates the Host header against the configured rp_id. A request arriving with a
mismatched host gets 421 Misdirected Request.
This matters because WebAuthn credentials are cryptographically bound to the RP ID. Accepting an
arbitrary Host would let a request through under a name the browser will not honor at
authentication time.
Configure your proxy to pass the original host through:
- nginx —
proxy_set_header Host $host; - HAProxy — preserved by default
- AWS NLB (TCP passthrough) — no HTTP layer, so
Hostis never rewritten - Kubernetes ingress-nginx — preserved by default
A blanket 421 across every request almost always means the proxy is rewriting Host to the
backend’s address. On the HTTP→HTTPS redirect listener, the redirect target is built from the
configured rp_id rather than the incoming header, so a mismatched host cannot be used to bounce
users to another origin.
Ports
When TLS is configured, the listen ports are fixed and VOUCH_LISTEN_ADDR is ignored:
| Port | Purpose | Configurable |
|---|---|---|
| 443 | HTTPS | No — fixed when TLS is configured |
| 80 | HTTP→HTTPS redirect (308), plus /health | No |
| 8443 | mTLS listener for certificate-bound tokens | Yes — VOUCH_MTLS_PORT |
Without TLS configured, the server listens on VOUCH_LISTEN_ADDR (default [::]:3000) and none of
the above applies. That is the mode to use when the proxy terminates TLS.
The mTLS listener starts automatically whenever TLS is configured — there is no flag to disable it. Firewall rules and security groups that open only 80 and 443 will silently break certificate-bound tokens. See TLS, Ports, and mTLS.
Binding to 80 and 443 requires CAP_NET_BIND_SERVICE on Linux. The RPM and DEB packages set this
up; a bind failure on port 80 is logged as a warning and is not fatal, so a deployment can lose its
HTTP redirect without otherwise failing.
Timeouts and body limits
Vouch applies a 30-second global request timeout and a 256 KiB global body limit, with tighter per-route limits (8 KiB for credential issuance, 64 KiB for SCIM and SAML ACS). Set your proxy’s timeouts at or above 30 seconds so that Vouch, not the proxy, produces the timeout response; and do not set a body limit below Vouch’s, or you will convert precise 413s into opaque proxy errors.
Example configurations
nginx (TLS passthrough — recommended)
stream {
upstream vouch {
server 10.0.1.10:443;
}
server {
listen 443;
proxy_pass vouch;
}
}
With passthrough there is no X-Forwarded-For: Vouch sees the client’s real address as the TCP
peer, so leave VOUCH_TRUSTED_PROXIES unset.
nginx (TLS terminated at the proxy)
server {
listen 443 ssl;
server_name auth.example.com;
ssl_certificate /etc/nginx/certs/auth.example.com.pem;
ssl_certificate_key /etc/nginx/certs/auth.example.com.key;
location / {
proxy_pass http://10.0.1.10:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
On the Vouch side, leave VOUCH_TLS_CERT and VOUCH_TLS_KEY unset, set
VOUCH_LISTEN_ADDR=0.0.0.0:3000, set VOUCH_BASE_URL=https://auth.example.com so issued URLs use
the public scheme and host, and set VOUCH_TRUSTED_PROXIES to the nginx host’s range.
AWS Network Load Balancer (TCP passthrough — recommended)
An NLB with TCP listeners forwards bytes untouched, so Vouch terminates TLS itself and keeps its cipher policy, its hybrid post-quantum key exchange, and its mTLS listener. Prefer it to an Application Load Balancer, which is HTTP-aware and always terminates TLS: that gives up all three and breaks RFC 8705 certificate-bound tokens, because the client certificate never reaches Vouch.
Use three TCP listeners, each forwarding to a TCP target group on the same port. A TLS target group would terminate TLS at the load balancer instead.
| Listener | Target group | Carries |
|---|---|---|
| TCP 443 | TCP 443 | HTTPS |
| TCP 80 | TCP 80 | HTTP, which Vouch answers with a 308 to HTTPS |
| TCP 8443 | TCP 8443 | mTLS, for certificate-bound tokens |
NLB listeners have no redirect action of their own, which is why port 80 forwards to the instances and Vouch issues the redirect itself. Omit the 8443 listener only if you do not use certificate-bound tokens.
Health-check the 443 target group with protocol HTTPS and path /health/ready. The health
check’s Host header is the load balancer node’s IP rather than your domain. That passes, because
Vouch validates Host only on the port 80 redirect listener. Do not health-check port 80 instead:
it serves /health but not /health/ready, so it reports a live process even when the database is
unreachable.
On the Vouch side, set VOUCH_TLS_CERT and VOUCH_TLS_KEY, set VOUCH_BASE_URL to the public
HTTPS URL, and leave VOUCH_TRUSTED_PROXIES unset.
TCP passthrough has no X-Forwarded-For, so the TCP peer address is the only client identity Vouch
sees and VOUCH_TRUSTED_PROXIES cannot recover it. AWS enables client IP preservation by default
for instance targets, but disables it for IP targets on TCP and TLS target groups. When it is off,
every request appears to come from a load balancer node: all users share one rate-limit bucket and
audit events record no real client address. Set preserve_client_ip.enabled to true on IP target
groups.
If cross-zone load balancing is disabled, register a target in every Availability Zone the load balancer has a node in. A zone whose node has no local target does not fail fast — it holds the connection for several seconds before falling back to another zone, which surfaces as intermittent multi-second latency rather than as an error.
Checklist
With TCP passthrough:
- Client IP preservation is on for the target group, and
VOUCH_TRUSTED_PROXIESis unset - Listeners exist for 443, 80, and 8443 if you use certificate-bound tokens
- Health checks use HTTPS on 443, not HTTP on 80
With TLS terminated at the proxy:
-
VOUCH_TRUSTED_PROXIEScovers your proxy ranges, and nothing wider - The proxy forwards the original
Host - The proxy appends to
X-Forwarded-Forrather than replacing it
Either way:
- Health checks target
/health/ready, not/health -
VOUCH_BASE_URLis the public URL clients use - Proxy timeouts are at least 30 seconds
- Audit events at
/admin/auditshow real client IPs
Identity Provider Overview
Vouch uses one or more upstream identity providers (IdPs) to verify user identity during enrollment. This links a trusted corporate identity to a hardware-bound FIDO2 credential.
Purpose
- Verify the user is a member of your organization during enrollment
- Pull user attributes (email) from your existing identity system
- No separate user database to maintain in Vouch
Supported Protocols
Vouch supports two upstream IdP protocols, configured as a unified list:
| Protocol | Use Case |
|---|---|
| OIDC (OpenID Connect) | Recommended for most deployments. Supports auto-discovery of endpoints. |
| SAML 2.0 | For organizations that require SAML or where OIDC is not available. |
Multiple IdPs — of either protocol, in any combination — can be configured simultaneously. The login page renders one “Sign in with X” button per configured IdP, in the order operators listed them.
OIDC Discovery
When using OIDC, the server automatically discovers authorization, token, and JWKS endpoints by fetching the /.well-known/openid-configuration document from the issuer URL at startup. Any OIDC-compliant provider works — no manual endpoint configuration is needed.
Supported Providers
| Provider | Protocol | Guide |
|---|---|---|
| Google Workspace | OIDC | Google Workspace (OIDC) |
| Microsoft Entra ID | OIDC or SAML | Entra ID (OIDC), SAML 2.0 |
| Okta | OIDC or SAML | Generic OIDC, SAML 2.0 |
| Keycloak | OIDC or SAML | Generic OIDC, SAML 2.0 |
| Auth0 | OIDC | Generic OIDC |
| Any OIDC-compliant provider | OIDC | Generic OIDC |
| Any SAML 2.0-compliant provider | SAML | SAML 2.0 |
Configuration
IdPs are configured as a unified list. Each IdP has an operator-chosen slug (e.g., google, entra, corp-saml) that becomes its identifier in the state table, login page query string (?provider=<slug>), and audit logs.
Slug rules
- Match
[a-z0-9-]{1,32} - Must not start or end with a hyphen
- Must be unique across all configured IdPs
Environment variables
Set VOUCH_IDPS to a comma-separated list of slugs. For each slug, set VOUCH_IDP_<SLUG>_TYPE to oidc or saml, plus the type-specific variables.
OIDC example (Google + Entra concurrently):
VOUCH_IDPS=google,entra
VOUCH_IDP_GOOGLE_TYPE=oidc
VOUCH_IDP_GOOGLE_ISSUER=https://accounts.google.com
VOUCH_IDP_GOOGLE_CLIENT_ID=<your-google-client-id>
VOUCH_IDP_GOOGLE_CLIENT_SECRET=<your-google-client-secret>
VOUCH_IDP_ENTRA_TYPE=oidc
VOUCH_IDP_ENTRA_ISSUER=https://login.microsoftonline.com/organizations/v2.0
VOUCH_IDP_ENTRA_CLIENT_ID=<your-entra-client-id>
VOUCH_IDP_ENTRA_CLIENT_SECRET=<your-entra-client-secret>
VOUCH_ALLOWED_DOMAINS=company.com
SAML example (mixed alongside OIDC):
VOUCH_IDPS=google,corp-saml
VOUCH_IDP_GOOGLE_TYPE=oidc
VOUCH_IDP_GOOGLE_ISSUER=https://accounts.google.com
VOUCH_IDP_GOOGLE_CLIENT_ID=<your-google-client-id>
VOUCH_IDP_GOOGLE_CLIENT_SECRET=<your-google-client-secret>
VOUCH_IDP_CORP_SAML_TYPE=saml
VOUCH_IDP_CORP_SAML_METADATA_URL=https://idp.example.com/saml/metadata
VOUCH_IDP_CORP_SAML_SP_ENTITY_ID=https://auth.example.com
VOUCH_IDP_CORP_SAML_EMAIL_ATTRIBUTE=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
VOUCH_IDP_CORP_SAML_DOMAIN_ATTRIBUTE=department
Hyphens in the slug become underscores in env-var names: corp-saml becomes VOUCH_IDP_CORP_SAML_*.
S3 configuration
In production deployments using S3-backed configuration, IdPs live under the top-level idps array. Each entry has id, type, and type-specific fields:
{
"idps": [
{
"id": "google",
"type": "oidc",
"issuer": "https://accounts.google.com",
"client_id": "<your-google-client-id>",
"client_secret": "<your-google-client-secret>"
},
{
"id": "corp-saml",
"type": "saml",
"metadata_url": "https://idp.example.com/saml/metadata",
"sp_entity_id": "https://auth.example.com",
"email_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"domain_attribute": "department"
}
]
}
Order in the idps array controls login-page button order.
Claims and Attribute Mapping
OIDC Claims
| OIDC Claim | Vouch Attribute | Required |
|---|---|---|
email | User email / principal | Yes |
email_verified | Email verification status | Yes (must be true) |
sub | Upstream subject, bound to the account together with the token issuer (see Account linking) | Yes |
hd | Google Workspace hosted domain | No (Google-specific) |
tid | Entra tenant ID | No (Entra-specific, cross-checked against issuer UUID to prevent cross-tenant token injection) |
SAML Attributes
| SAML Attribute | Vouch Attribute | Notes |
|---|---|---|
Configurable via VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTE | User email / principal | Falls back to NameID if not found |
Configurable via VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTE | Domain for enrollment restriction | Extracted from email if not set |
Account Linking and Identity Binding
Email addresses are a lease, not a name: employers reassign them and providers recycle
them. Vouch therefore treats the upstream identity pair — the validated OIDC (iss, sub)
claims, or for SAML the IdP entity ID plus NameID — as the durable link between a Vouch
account and a person. The email address is profile data.
When an IdP sign-in completes, Vouch resolves the account in this order:
- Binding match. An account already bound to this exact issuer + subject signs in, even if the asserted email has since changed upstream. The account email is canonical; a drifted upstream email is logged but never written back.
- Email match with lazy binding. An account with a matching email and no binding for
this issuer is bound to the asserted issuer + subject on the spot (audit event
identity_bound), provided the sign-in asserts a subject at all — see the SAML caveat below. This is how accounts that predate identity binding, and SCIM-provisioned accounts, acquire their binding — there is no batch backfill; each account binds on its first eligible IdP sign-in. - Refusal when the bound subject can’t be reasserted. If the email matches an account
already bound for this issuer, and the sign-in either asserts a different subject or
asserts none at all (e.g. a SAML NameID format identity binding doesn’t trust — see
SAML 2.0), the sign-in is refused with an “Account Linking Blocked” error
page and an
identity_bind_refusedaudit event. This is deliberate: an email match that can’t reassert the bound identity is what an upstream email reassignment (and the resulting account-takeover attempt) looks like, and a sign-in this weak must not fall back to matching on email alone once an account is bound. - New account. No match creates a new account carrying the binding.
Bindings are per-issuer: an account can hold one binding for each configured IdP, so multi-IdP deployments and IdP migrations work without intervention — the first sign-in through a newly configured IdP adds a binding for that issuer alongside the existing ones.
Recovery. If an IdP legitimately re-issues subjects (e.g. a directory tenant rebuild), affected users are refused at step 3 and cannot sign in. There is currently no unbind operation: an org admin must remove the affected user (Admin → Members → Remove), after which the user re-enrolls and a fresh account binds to the new subject.
SAML deployments must send a persistent-format NameID to get identity binding — it is
the only format the SAML spec guarantees is stable per principal. Every other format
(emailAddress, unspecified, transient, or a missing Format attribute) cannot create
a binding: for an account with no existing binding for the IdP, matching falls back to
email alone, as it did before identity binding existed; for an account that already has a
binding, such a sign-in hits step 3 above and is refused instead — see SAML 2.0
for details.
User Lifecycle
- User exists in external IdP but not Vouch — Enrollment creates Vouch user
- User removed from external IdP — Existing Vouch sessions continue until expiry; re-enrollment blocked
Google Workspace (OIDC)
Configure Google Workspace as your external identity provider for Vouch enrollment via OpenID Connect.
Prerequisites
- Google Workspace admin access
- A verified domain in Google Workspace
Step 1: Create OAuth Client in Google Cloud Console
- Go to Google Cloud Console
- Select or create a project
- Navigate to APIs & Services > Credentials
- Click Create Credentials > OAuth client ID
- Select Web application as the application type
- Configure:
- Name:
Vouch - Authorized redirect URIs:
https://auth.example.com/oauth/callback
- Name:
- Click Create
- Copy the Client ID and Client Secret
Step 2: Configure Consent Screen
- Navigate to APIs & Services > OAuth consent screen
- Select Internal (restricts to your Google Workspace org)
- Configure:
- App name:
Vouch - User support email: your admin email
- Authorized domains: your Vouch server domain
- App name:
- Add scopes:
openid,email,profile
Step 3: Configure Vouch
Add Google to the VOUCH_IDPS list with type oidc:
VOUCH_IDPS=google
VOUCH_IDP_GOOGLE_TYPE=oidc
VOUCH_IDP_GOOGLE_ISSUER=https://accounts.google.com
VOUCH_IDP_GOOGLE_CLIENT_ID=<your-client-id>.apps.googleusercontent.com
VOUCH_IDP_GOOGLE_CLIENT_SECRET=<your-client-secret>
To run Google alongside another IdP (e.g., Microsoft Entra), append both slugs to VOUCH_IDPS — both buttons appear on the login page in list order.
The server automatically discovers Google’s authorization, token, and JWKS endpoints via OIDC Discovery. No manual endpoint configuration is needed.
Optionally restrict enrollment to specific domains:
VOUCH_ALLOWED_DOMAINS=example.com,subsidiary.com
S3 configuration
{
"idps": [
{
"id": "google",
"type": "oidc",
"issuer": "https://accounts.google.com",
"client_id": "<your-client-id>.apps.googleusercontent.com",
"client_secret": "<your-client-secret>"
}
]
}
Step 4: Test
- Run
vouch enrollon a workstation - The browser redirects to Google sign-in
- After signing in, complete the WebAuthn registration with your YubiKey
Claims Mapping
| Google Claim | Vouch Attribute |
|---|---|
email | User email / principal |
name | Display name |
email_verified | Must be true |
Troubleshooting
“Access blocked: This app’s request is invalid”
- Verify the redirect URI exactly matches
https://<your-vouch-domain>/oauth/callback
“This app is not verified”
- Set the OAuth consent screen to Internal for Google Workspace
Users from wrong domain can enroll
- Set
VOUCH_ALLOWED_DOMAINSto restrict enrollment to specific email domains
Microsoft Entra ID (OIDC)
Configure Microsoft Entra ID (formerly Azure AD) as your upstream identity provider for Vouch enrollment.
Prerequisites
- Microsoft Entra ID tenant with admin access
- An app registration in the Azure portal
Step 1: Register an Application in Entra ID
Follow Microsoft’s app registration guide to create a new app registration:
- Sign in to the Azure portal
- Navigate to Microsoft Entra ID > App registrations > New registration
- Configure:
-
Name:
Vouch -
Supported account types: choose one of:
- Accounts in this organizational directory only (single-tenant) — restrict to your tenant.
- Accounts in any organizational directory (Any Microsoft Entra ID tenant – Multitenant) — let users from any work/school tenant enroll.
Do not select “Accounts in any organizational directory and personal Microsoft accounts” or “Personal Microsoft accounts only”. Personal Microsoft accounts (outlook.com, hotmail.com, live.com) are not supported by Vouch — see Why personal accounts aren’t supported below.
-
Redirect URI: Select Web and enter
https://auth.example.com/oauth/callback
-
- Click Register
Step 2: Create a Client Secret
- In the app registration, go to Certificates & secrets > Client secrets
- Click New client secret
- Set a description and expiry period
- Copy the Value (not the Secret ID) immediately — it is only shown once
Step 3: Configure the xms_edov optional claim
Vouch requires the xms_edov (Email Domain Owner Verified) claim to confirm the user’s email is admin-verified by their tenant. Microsoft does not emit this claim by default — you must add it as an optional claim:
- In the app registration, go to Token configuration > Add optional claim
- Select token type ID
- Check xms_edov
- Click Add
- If prompted to “Turn on the Microsoft Graph email permission”, accept
The next ID token Microsoft issues will contain "xms_edov": true when the email’s domain is verified by the user’s tenant admin.
Step 4: Configure Vouch
Add Entra to the VOUCH_IDPS list with type oidc:
Single-tenant
VOUCH_IDPS=entra
VOUCH_IDP_ENTRA_TYPE=oidc
VOUCH_IDP_ENTRA_ISSUER=https://login.microsoftonline.com/{tenant-id}/v2.0
VOUCH_IDP_ENTRA_CLIENT_ID=<application-client-id>
VOUCH_IDP_ENTRA_CLIENT_SECRET=<client-secret-value>
Replace {tenant-id} with your Entra ID tenant ID (found in Azure portal > Microsoft Entra ID > Overview).
Multi-tenant (any work/school tenant)
VOUCH_IDP_ENTRA_ISSUER=https://login.microsoftonline.com/organizations/v2.0
Vouch handles the {tenantid} template issuer that /organizations/ discovery returns, then cross-checks the per-tenant tid claim in each ID token against the tenant UUID in the token’s iss claim to prevent cross-tenant token injection.
The server automatically discovers authorization, token, and JWKS endpoints from the issuer URL via OIDC Discovery. No manual endpoint configuration is needed.
Optionally restrict enrollment to specific email domains:
VOUCH_ALLOWED_DOMAINS=example.com
S3 configuration
{
"idps": [
{
"id": "entra",
"type": "oidc",
"issuer": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"client_id": "<application-client-id>",
"client_secret": "<client-secret-value>"
}
]
}
Step 5: Test
- Run
vouch enrollon a workstation - The browser redirects to the Microsoft sign-in page
- After signing in, complete the WebAuthn registration with your YubiKey
Why personal Microsoft accounts aren’t supported
Vouch issues hardware-backed credentials and tracks users by verified email address. To verify an Entra email, Vouch requires the xms_edov optional claim, which provides a signed assertion that the email’s domain is admin-verified.
Microsoft only emits optional claims (including xms_edov) for app registrations that target Microsoft Entra ID accounts only. Per Microsoft’s app manifest reference:
Apps that support both personal accounts and Microsoft Entra ID can’t use optional claims.
As a consequence, the https://login.microsoftonline.com/common/v2.0 issuer is rejected at startup — Vouch will refuse to load an Entra IdP configured with /common/. Use /organizations/v2.0 or a single-tenant URL instead.
Common Pitfalls
InvalidIssuer at the callback
The configured issuer must end in /v2.0. The v1 endpoints use a different token format and are not compatible with standard OIDC discovery.
Email address is not verified by the identity provider after sign-in
The xms_edov optional claim isn’t configured on the app registration. Repeat Step 3. If the claim is already configured but the user’s email still isn’t accepted, the user’s tenant admin has not verified the email domain — Microsoft only sets xms_edov: true when the domain is verified inside the user’s tenant.
Redirect URI mismatch
The redirect URI in the app registration must exactly match https://<your-vouch-domain>/oauth/callback. Azure does not support wildcard redirect URIs.
Multi-tenant without an allowlist
If you use /organizations/v2.0, any Entra tenant can attempt enrollment. Set VOUCH_ALLOWED_DOMAINS to restrict which email domains Vouch will accept.
Generic OIDC Provider
Configure any OpenID Connect-compliant identity provider as the upstream IdP for Vouch enrollment.
Prerequisites
- Your IdP must support OIDC Discovery (a
/.well-known/openid-configurationendpoint at the issuer URL) - You need a registered OAuth 2.0 client (client ID and client secret)
- The redirect URI
https://<your-vouch-domain>/oauth/callbackmust be registered with the IdP
Finding the Issuer URL
The issuer URL is the base URL that hosts the OIDC discovery document. You can verify it by fetching {issuer}/.well-known/openid-configuration and confirming it returns a valid JSON document:
curl -s https://your-idp.example.com/.well-known/openid-configuration | jq .issuer
Common issuer URL patterns:
| Provider | Issuer URL Format |
|---|---|
| Okta | https://{your-domain}.okta.com or https://{your-domain}.okta.com/oauth2/{auth-server-id} |
| Keycloak | https://{host}/realms/{realm} |
| Auth0 | https://{tenant}.auth0.com/ |
| Google Workspace | https://accounts.google.com |
| Entra ID | https://login.microsoftonline.com/{tenant-id}/v2.0 |
Configuration
Pick a slug for the IdP (e.g., okta, keycloak, auth0-corp) and add it to VOUCH_IDPS. Slug rules: [a-z0-9-]{1,32}, no leading or trailing hyphen, unique across IdPs.
VOUCH_IDPS=okta
VOUCH_IDP_OKTA_TYPE=oidc
VOUCH_IDP_OKTA_ISSUER=https://your-idp.example.com
VOUCH_IDP_OKTA_CLIENT_ID=<your-client-id>
VOUCH_IDP_OKTA_CLIENT_SECRET=<your-client-secret>
Hyphens in the slug become underscores in env-var names: a slug of auth0-corp becomes VOUCH_IDP_AUTH0_CORP_*.
At startup, the server fetches the discovery document from {issuer}/.well-known/openid-configuration and automatically discovers the authorization, token, and JWKS endpoints. No manual endpoint configuration is needed.
S3 configuration
{
"idps": [
{
"id": "okta",
"type": "oidc",
"issuer": "https://your-idp.example.com",
"client_id": "<your-client-id>",
"client_secret": "<your-client-secret>"
}
]
}
Domain Restrictions
Restrict enrollment to specific email domains:
VOUCH_ALLOWED_DOMAINS=example.com,subsidiary.com
If not set, users from any email domain can enroll (provided they authenticate with the upstream IdP).
Tested Providers
These providers are tested with Vouch:
| Provider | Notes |
|---|---|
| Google Workspace | See dedicated guide |
| Microsoft Entra ID | See dedicated guide |
| Okta | Use the Org Authorization Server or a custom one |
| Keycloak | Requires a configured realm with client credentials |
| Auth0 | Use the tenant issuer URL with trailing slash |
Troubleshooting
“Failed to fetch upstream OIDC discovery document”
- Verify the issuer URL is correct and reachable from the server
- Verify the URL uses HTTPS (HTTP is only allowed for
localhost) - Confirm the discovery endpoint returns valid JSON
“Issuer mismatch”
- The
issuerfield in the discovery document must exactly match the configuredVOUCH_IDP_<SLUG>_ISSUERvalue (trailing slashes matter). Entra/organizations/v2.0is special-cased automatically — its discovery document returns a{tenantid}template that vouch handles transparently. The/common/v2.0endpoint is not supported; see Microsoft Entra ID.
Token errors after authentication
- Verify the client secret is correct and not expired
- Verify the redirect URI registered with the IdP exactly matches
https://<your-vouch-domain>/oauth/callback
SAML 2.0
Configure one or more SAML 2.0 identity providers for Vouch enrollment. Vouch acts as a SAML Service Provider (SP) with HTTP-POST and HTTP-Redirect bindings.
SAML IdPs are configured under the same VOUCH_IDPS list as OIDC IdPs and can coexist with OIDC providers in any combination.
Environment Variables
Pick a slug for the IdP (e.g., corp-saml, partner-saml) and add it to VOUCH_IDPS. Slug rules: [a-z0-9-]{1,32}, no leading/trailing hyphen, unique across IdPs.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_IDP_<SLUG>_TYPE | Yes | (none) | Must be saml for a SAML IdP. |
VOUCH_IDP_<SLUG>_METADATA_URL | Yes | (none) | URL to the IdP’s SAML metadata XML document. Fetched at server startup. |
VOUCH_IDP_<SLUG>_SP_ENTITY_ID | No | {VOUCH_BASE_URL} | SP entity ID sent in authentication requests. Defaults to the server’s base URL. |
VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTE | No | (auto-detect) | SAML attribute name containing the user’s email address. |
VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTE | No | (none) | SAML attribute name containing the user’s domain (for domain restriction). |
Hyphens in the slug become underscores in env-var names: a slug of corp-saml becomes VOUCH_IDP_CORP_SAML_*.
SP Metadata
The Vouch server exposes SP metadata for configuring your IdP:
GET https://auth.example.com/saml/metadata
This returns an XML document containing the SP entity ID, Assertion Consumer Service (ACS) URL, and supported bindings. The SP entity ID comes from the first configured SAML IdP (or the server’s base URL if none is set). Import the metadata into your IdP or configure the following values manually:
- SP Entity ID:
https://auth.example.com(or the value ofVOUCH_IDP_<SLUG>_SP_ENTITY_ID) - ACS URL:
https://auth.example.com/saml/acs - Bindings: HTTP-POST (ACS), HTTP-Redirect (AuthnRequest)
All configured SAML IdPs share the single ACS URL; Vouch identifies the originating IdP via the per-request RelayState stored in the state table.
Attribute Mapping
Vouch extracts user identity from SAML assertion attributes. By default, it looks for the email address in common attribute names. You can override this per-IdP with VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTE:
| Use Case | Attribute Example |
|---|---|
| Standard email | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress |
| NameID | (used automatically if no attribute match) |
| Custom | Set VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTE to your IdP’s attribute name |
For domain-based enrollment restrictions, set VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTE to the attribute name containing the user’s domain. If not set, the domain is extracted from the email address.
NameID Stability
Vouch binds accounts to the pair (IdP entity ID, NameID) — not to the email address — so
the NameID must be a stable, non-reassignable identifier for the user (see
Account linking). Only the
urn:oasis:names:tc:SAML:2.0:nameid-format:persistent format is eligible for identity
binding: it is the one NameID format the SAML 2.0 spec defines specifically for durable
cross-session account linking. Configure the IdP to send a persistent NameID.
Every other format cannot create a binding, and Vouch does not attempt to bind an identity from it:
transient— a fresh value on every login; binding it would treat each login as a different person.emailAddress— carries the same reassignment risk as the email address itself, so it cannot serve as the durable link that binding requires.unspecified, or a missingFormatattribute — the SAML spec gives no stability guarantee for these, and some IdPs mint a new value per login under this format without declaringtransient. Treating it as stable would bind the first login’s value and then refuse every later login as an identity conflict once the IdP sends a different one.
For an account with no existing binding for this IdP, a sign-in in one of these formats
matches on email alone, exactly as it did before identity binding existed. For an account
that already has a binding for this IdP — established by an earlier persistent-format
sign-in — a later sign-in in one of these formats is refused with the same “Account Linking
Blocked” error as a subject mismatch: it cannot reassert the bound identity, so it must not
be allowed to fall back to a weaker, email-only check. This is what stops a mix of NameID
formats from the same IdP from reopening the account-takeover risk identity binding closes.
Deployments on a non-persistent format keep working exactly as they did before identity
binding existed, but without the reassignment protection it provides — switch the IdP to
send a persistent NameID to enable it.
Configuration Example
VOUCH_IDPS=corp-saml
VOUCH_IDP_CORP_SAML_TYPE=saml
VOUCH_IDP_CORP_SAML_METADATA_URL=https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml
VOUCH_IDP_CORP_SAML_SP_ENTITY_ID=https://auth.example.com
VOUCH_IDP_CORP_SAML_EMAIL_ATTRIBUTE=http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
VOUCH_ALLOWED_DOMAINS=example.com
S3 configuration
{
"idps": [
{
"id": "corp-saml",
"type": "saml",
"metadata_url": "https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml",
"sp_entity_id": "https://auth.example.com",
"email_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
}
]
}
Provider-Specific Notes
Microsoft Entra ID
- Metadata URL:
https://login.microsoftonline.com/{tenant-id}/federationmetadata/2007-06/federationmetadata.xml - Email attribute:
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress - Create an Enterprise Application with SAML SSO and set the ACS URL to
https://auth.example.com/saml/acs
Okta
- Metadata URL: found in the Okta SAML app’s Sign On tab under Metadata URL
- Email attribute:
email, set in the Okta attribute statements - Set the Single Sign-On URL to
https://auth.example.com/saml/acsand Audience URI to your SP entity ID
Google Workspace
- Metadata URL: available from the Google Admin console under Apps > Web and mobile apps > SAML app > Metadata
- Email attribute:
email(configure in attribute mapping) - Add a custom SAML app in the Admin console with ACS URL
https://auth.example.com/saml/acs
Troubleshooting
“Failed to fetch SAML IdP metadata”
- Verify the metadata URL is correct and reachable from the server
- Verify the URL returns valid XML (not an HTML login page)
- Confirm the server can make outbound HTTPS requests
Signature verification errors
- Confirm the IdP’s signing certificate in the metadata is current and not expired
- Confirm the server clock is NTP-synchronized. SAML assertions have time-based validity windows.
Email not extracted from assertion
- Check the SAML assertion attributes using debug logging (
RUST_LOG=vouch_server=debug) - Set
VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTEto the exact attribute name used by your IdP
Key Management
Vouch uses seven kinds of cryptographic keys. This page covers their lifecycle and rotation.
Key Inventory
| Key | Algorithm | Purpose | Storage |
|---|---|---|---|
| SSH CA Key | Ed25519 | Signs SSH user certificates | File, env var, S3 config, or KMS |
| OIDC Signing Key | P-256 EC (ES256) | Signs access tokens and ID tokens (default) | Env var, S3 config, or KMS |
| OIDC RSA Signing Key | RSA-3072 (RS256) | Signs ID tokens (per-client, OIDC Core conformance) | Env var, S3 config, or KMS |
| JWT Secret | HMAC-SHA256 | Signs internal state tokens (authorization codes, WebAuthn state, CSRF) | Env var, S3 config, or KMS |
| Document Encryption Key | P-384 EC (HPKE) | Encrypts sensitive documents stored alongside S3 config | S3 config (KMS-protected) |
| TLS Certificate | EC/RSA | HTTPS transport | Env var or S3 config |
| Client Key (per-CLI) | P-256 EC (ES256) | FAPI 2.0 client auth, DPoP proofs | OS keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager), file fallback |
All of these keys use classical (pre-quantum) algorithms. For why that is currently the right choice for each of them, and what Vouch already does about quantum resistance, see vouch.sh/docs/security. TLS key exchange is the one surface that is already hybrid post-quantum — see TLS, Ports, and mTLS.
SSH CA Key
The SSH CA key signs all SSH user certificates. Every host that trusts Vouch certificates must have the corresponding public key in TrustedUserCAKeys.
Generation
ssh-keygen -t ed25519 -f ssh_ca_key -N "" -C "vouch-ca@example.com"
Configuration
# Option 1: File path (raw OpenSSH PEM)
VOUCH_SSH_CA_KEY_PATH=./ssh_ca_key
# Option 2: Inline (raw or base64-encoded PEM; takes precedence over the file)
VOUCH_SSH_CA_KEY="$(base64 -i ssh_ca_key | tr -d '\n')"
# Option 3: AWS KMS (overrides Options 1 and 2)
VOUCH_SSH_CA_KMS_KEY_ID=mrk-1234abcd5678efgh
# Option 4: Disable SSH CA
VOUCH_SSH_CA_KEY_PATH=""
VOUCH_SSH_CA_KEY accepts either raw PEM or base64-encoded PEM — the server detects which by
looking for the -----BEGIN header. The file at VOUCH_SSH_CA_KEY_PATH must be raw PEM.
Warning: if the file at
VOUCH_SSH_CA_KEY_PATHdoes not exist, the server generates a new Ed25519 CA key and writes it there (mode0600, commentvouch-ca@{rp_id}). That is convenient on first install and dangerous afterwards: starting on a fresh container, an empty volume, or an unmounted data directory silently issues you a brand-new CA. Every host’sTrustedUserCAKeysentry stops matching and users can no longer log in with newly issued certificates, while/healthstays green. PreferVOUCH_SSH_CA_KEYorVOUCH_SSH_CA_KMS_KEY_IDfor anything beyond a single-node install — neither auto-generates.
When using KMS, the server calls kms:Sign with Ed25519. The KMS key must be an asymmetric signing key with ECC_EDWARDS_CURVE_25519 key spec. Use a multi-region key (mrk- prefix) for high availability.
Rotation
SSH CA key rotation requires coordinated updates:
- Generate a new CA key
- Distribute the new public key to all hosts (add to
TrustedUserCAKeys) - Update the Vouch server configuration with the new private key
- Restart the server
- After all existing certificates expire (max 8 hours), remove the old public key from hosts
Important: During rotation, keep both the old and new CA public keys in
TrustedUserCAKeysuntil step 5 — removing the old key early invalidates certificates that have not yet expired.
Public Key Distribution
Retrieve the CA public key. The endpoint returns JSON, not an authorized-keys line:
curl -s https://auth.example.com/v1/credentials/ssh/ca
# {"public_key":"ssh-ed25519 AAAA...","comment":"vouch-ca@example.com"}
To write a file that sshd can use as TrustedUserCAKeys, extract the public_key field:
curl -s https://auth.example.com/v1/credentials/ssh/ca \
| jq -r .public_key > /etc/ssh/vouch-ca.pub
Redirecting the raw response into that file writes JSON where
sshdexpects a key, and every certificate login then fails.
OIDC Signing Key (ES256)
Used to sign access tokens (RFC 9068) and ID tokens (default algorithm) with ES256.
Configuration
# Option 1: Local key (base64-encoded PEM)
VOUCH_OIDC_SIGNING_KEY="$(base64 -i oidc_signing_key.pem | tr -d '\n')"
# Option 2: AWS KMS (overrides Option 1)
VOUCH_OIDC_SIGNING_KMS_KEY_ID=mrk-abcd1234efgh5678
If neither is set, an ephemeral key is generated on startup. This means tokens cannot be verified after a server restart unless the same key is provided.
When using KMS, the server calls kms:Sign with P-256 ECDSA (ECC_NIST_P256 key spec). Use a multi-region key (mrk- prefix) for high availability.
Generation
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out oidc_signing_key.pem
Note: use
openssl genpkey, which produces PKCS#8, rather thanopenssl ecparam -genkey, which produces SEC1. The server accepts only PKCS#8. Tell them apart from the PEM header on the first line: PKCS#8 labels itPRIVATE KEY, while SEC1 labels itEC PRIVATE KEY. If you have a SEC1 key, convert it:openssl pkey -in sec1_key.pem -out pkcs8_key.pem
Rotation
When rotating the OIDC signing key:
- Generate a new key
- Update the server configuration
- Restart the server
- The JWKS endpoint (
/oauth/jwks) automatically serves the new public key - Relying parties that cache JWKS will pick up the new key on their next refresh
OIDC RSA Signing Key (RS256)
Used to sign ID tokens with RS256 algorithm per OIDC Core Section 3.1.3.7 and all AWS credential tokens. RS256 is the default id_token_signed_response_alg in the OIDC specification and must be supported for conformance. Clients can select RS256 via OAuth 2.0 Dynamic Client Registration (id_token_signed_response_alg field). The AWS token endpoint (/v1/credentials/aws/token) issues one RS256-signed token that serves both STS AssumeRoleWithWebIdentity and, as the sso-oidc:CreateTokenWithIAM assertion, the IAM Identity Center trusted-token-issuer contract (which rejects ES256).
Access tokens are always signed with ES256 (the OIDC Signing Key above).
Generation
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out oidc_rsa_key.pem
The server rejects keys smaller than 3072 bits at startup.
Configuration
# Option 1: Local key (base64-encoded PEM)
VOUCH_OIDC_RSA_SIGNING_KEY="$(base64 -i oidc_rsa_key.pem | tr -d '\n')"
# Option 2: AWS KMS (overrides Option 1)
VOUCH_OIDC_RSA_SIGNING_KMS_KEY_ID=mrk-rsa1234abcd5678
If neither is set, an ephemeral RSA-3072 key is generated on startup. This means RS256 ID tokens and AWS credential tokens cannot be verified after a server restart, and verification fails across multiple instances (each generates its own key). Any deployment using the AWS integration needs a durable key. A warning is logged when an ephemeral key is generated.
When using KMS, the key must be:
- Key spec:
RSA_3072 - Key usage:
SIGN_VERIFY - Signing algorithm:
RSASSA_PKCS1_V1_5_SHA_256
Use a multi-region key (mrk- prefix) for high availability.
Rotation
When rotating the OIDC RSA signing key:
- Generate a new RSA-3072 key
- Update the server configuration
- Restart the server
- The JWKS endpoint (
/oauth/jwks) automatically serves the new public key - Relying parties that cache JWKS will pick up the new key on their next refresh
JWT Secret
Used for signing internal state tokens (authorization codes, WebAuthn challenge state, CSRF tokens) with HS256. Access tokens are signed with the OIDC signing key (ES256) per RFC 9068.
Configuration
# Option 1: Local secret (must be at least 32 characters)
VOUCH_JWT_SECRET="$(openssl rand -base64 48)"
# Option 2: AWS KMS HMAC (eliminates the need for VOUCH_JWT_SECRET)
VOUCH_JWT_HMAC_KMS_KEY_ID=mrk-5678abcd1234efgh
When using KMS, the server uses kms:GenerateMac and kms:VerifyMac with HMAC-SHA256. The KMS key must be a HMAC_256 key type. Use a multi-region key (mrk- prefix) for high availability.
Generation (local secret)
openssl rand -base64 48
Rotation
Changing the JWT secret (or KMS key) invalidates all existing sessions. Users must re-authenticate.
- Generate a new secret or KMS key
- Update
VOUCH_JWT_SECRETorVOUCH_JWT_HMAC_KMS_KEY_ID - Restart the server
- All users must run
vouch loginagain
Document Encryption Key
Used for HPKE (Hybrid Public Key Encryption) of sensitive documents stored alongside the S3 configuration. The private key is encrypted by a KMS key and stored in the S3 config as document_key.
Provisioning
vouch-server generate-document-key --kms-key-id mrk-<your-kms-key-id>
This generates a P-384 EC key pair, encrypts the private key with the specified KMS key, and outputs the document_key JSON block to add to your S3 config. --algorithm p384 is the default and currently the only supported algorithm; the flag exists so post-quantum algorithms can be added later without changing the command or config shape.
Configuration
The document_key field in S3 config contains:
{
"document_key": {
"kms_key_id": "mrk-<your-kms-key-id>",
"encrypted_private_key": "<base64-encoded KMS ciphertext>",
"algorithm": "p384"
}
}
algorithm is optional and defaults to p384, so configs provisioned before the field existed keep working unchanged.
At startup, the server decrypts the private key via kms:Decrypt and holds the key material in memory for the lifetime of the process.
Cipher-suite tagging and the post-quantum path
Every document row records the HPKE cipher suite it was sealed with: the stored
encapsulated key is prefixed hpke:<kem_id>:<kdf_id>:<aead_id>: using the RFC 9180
codepoints (hpke:0011:0002:0002: for the current DHKEM(P-384) + HKDF-SHA384 +
AES-256-GCM suite). Rows written before tagging existed are plain base64 and are read
as that same P-384 suite. Rows sealed under different suites can therefore coexist in
one database, which is what makes a future key-encapsulation migration — e.g. to the
ML-KEM hybrid suites from draft-ietf-hpke-pq — an operational rotation rather than a
breaking format change.
There is no document-key rotation mechanism today. When post-quantum suites become available in the underlying libraries (rustls / aws-lc-rs), the expected migration is:
- Provision a new
document_keywith the new algorithm (one newgenerate-document-key --algorithmvalue). - Run a dual-key read period: the server decrypts old rows with the old private key (selected by each row’s suite tag) while sealing new writes under the new suite.
- Re-encrypt existing rows opportunistically on write, plus an offline sweep for the remainder; then retire the old key.
Steps 2–3 are not implemented yet — only the storage format and configuration groundwork exist. Do not remove the old key from KMS until every row carries the new suite tag.
Per-Org Issuer Signing Keys
Vouch can give an organization its own OIDC issuer host with a dedicated signing key set, so that
a token issued for one organization does not verify against another’s JWKS. This only activates on
a deployment that has both document encryption (a KMS-backed document_key in the S3 config) and
an organization that has claimed an issuer subdomain — the shape used by the hosted Vouch service.
A single-organization self-hosted deployment does not use it: every token is signed with the platform keys described above.
Startup invariant: if any issuer subdomain is claimed in the database but document encryption is not configured, the server refuses to start. Per-org private keys are never stored in plaintext, so the encrypting document store is a hard requirement. See Troubleshooting.
TLS Certificate
See TLS Configuration for details on TLS certificate management and hot-reload.
Organizations and Administrators
Vouch groups users into organizations, and administration happens per organization. This chapter covers where organizations come from, how the first administrator is created, and what an administrator can do.
Organizations are created automatically
There is no “create organization” step, no command, and no admin screen for it. An organization is created the first time somebody enrolls from a given email domain: the server derives the domain from the verified email its identity provider returned, looks for an organization owning that domain, and creates one if none exists.
The consequence worth internalizing: your organization comes into existence when your first user enrolls, not when you install the server. A freshly started server has no organizations, no users, and no administrators, and there is nothing you can usefully click until somebody enrolls.
Users whose identity provider returns no hosted-domain information enroll without an organization. They can log in and receive credentials normally, but they are not part of any organization and cannot be managed through SCIM.
The first enrollee becomes the administrator
The first user to enroll from a domain is made that organization’s administrator automatically.
Plan the first enrollment. Whoever enrolls first from your domain holds the only administrator account, and every subsequent administrator is promoted by an existing one. Enroll deliberately — ideally the person who will own the deployment — rather than letting an arbitrary early user claim it.
Restrict who can enroll at all with VOUCH_ALLOWED_DOMAINS. When it is unset, enrollment is open
to any email domain your identity provider will authenticate, which the server records in its
startup log as open enrollment.
Administration is per-organization
An administrator administers their own organization and nothing else. There is no global administrator, no super-user, and no cross-organization console anywhere in the product. An administrator attempting to act on a user in another organization is rejected.
Administrators also cannot act on themselves: promote, demote, deactivate, and remove all refuse when the target is the acting administrator. This prevents an organization from locking itself out by demoting or deleting its only administrator.
Accessing the admin UI
The admin pages live under /admin and require a signed-in session belonging to a user who is
both active and an administrator. Sign in at your server’s login page and go to /admin.
The same actions are available programmatically under /api/v1/org/* using a Bearer access token
from a regular FIDO2 session — this is what the SCIM chapter uses.
| Page | Path | Covered in |
|---|---|---|
| Members | /admin | This page |
| Audit log | /admin/audit | Audit Events |
| Posture policies | /admin/policies | Posture Policies |
| SCIM tokens | /admin/scim-tokens | SCIM Provisioning |
| Email domains | /admin/domains | Email Domains |
Member actions
All of these are on the Members page, and every one writes an audit event.
| Action | Effect |
|---|---|
| Promote | Grants administrator rights. Audited as admin_promote. |
| Demote | Removes administrator rights; the account otherwise keeps working. Audited as admin_demote. |
| Deactivate | Marks the account inactive, deletes all of its sessions, revokes all of its SSH certificates, and clears any stored GitHub refresh token. A deactivated account is refused everywhere it could sign back in — browser and SAML SSO (audited as login_failed with reason user_deactivated), the device-authorization flow, and hardware-key registration — until reactivated here or via SCIM active: true. Enrolled authenticators are kept, so reactivating restores access without re-enrollment. Audited as admin_deactivate. |
| Activate | Reverses a deactivation. The user must sign in again — deactivation already destroyed their sessions. Audited as admin_activate. |
| Revoke credentials | Deletes every enrolled authenticator, deletes all sessions, revokes all SSH certificates, and clears the GitHub refresh token — but keeps the account. The user must enroll a hardware key again before they can log in. Audited as admin_revoke_credentials. |
| Remove | Revokes the user’s SSH certificates and then deletes the user record, cascading to their authenticators. Audited as admin_remove_user. |
Choosing between them
- Someone lost their YubiKey → Revoke credentials. It clears the enrolled authenticators so they can enroll a replacement, without deleting the account or its history.
- Someone is on leave, or you are responding to a suspected compromise → Deactivate. It cuts off access immediately and is reversible, because their authenticators survive.
- Someone has left the organization → Remove, or let SCIM de-provisioning do it for you. See SCIM Provisioning, which performs the equivalent automatically when your IdP deletes the user.
All three revoke live sessions immediately. None of them wait for token expiry.
What administrators cannot do
- Create organizations, or move a user between organizations
- Create users — enrollment is always initiated by the user with their hardware key present
- Recover or export any private key material
- Administer another organization
- Change server configuration — that is environment variables or the S3 document, and requires a restart. See Configuration Sources.
Posture Policies
Posture policies let you require that a user’s device meets a security standard before Vouch will issue them credentials. A laptop without full-disk encryption, or running an unsupported OS version, can be refused a token even though the user holds a valid hardware key.
Policies also cover timing: a policy can require a recent hardware login before workload credentials are issued, cap how many tokens a user obtains per hour, or refuse credentials after a logout. These read the user’s recent authentication history rather than their device.
Migrating from CEL. Policies are written in Dogwood (Cedar plus temporal conditions). Custom policies written for the previous CEL engine are rejected when you edit them and fail closed at login until re-authored — see Rewriting a CEL policy.
Manage them at /admin/policies. The page shows one list of every policy — built-in and custom,
active first, each tagged with its source — with the caps in the header (20 custom policies per
organization, 10 active at once alongside the built-ins). A policy’s Dogwood source is behind its row’s expando; the list
itself shows name, description, and state.
Custom policies are written with a guided builder: pick the decision point, add conditions from typed dropdowns, and the generated rule previews live. Raw Dogwood text remains available for anything the builder does not cover — see Custom policies.
How enforcement works
The vouch CLI collects device posture attributes locally and sends them with the FIDO2 token
request. The server evaluates your active policies against those attributes after verifying the
FIDO2 assertion and before issuing the access token.
Policies are enforced at two points:
| Decision | When | Policies that apply |
|---|---|---|
| Token issuance | vouch login (FIDO2 assertion grant) | Device posture, plus history policies that count prior activity |
| Token exchange | Workload identity and agent credentials (RFC 8693) | History policies only — an exchange carries no device posture |
Recency policies (“logged in within 15 minutes”) deliberately gate exchange, not login: the login itself is a hardware authentication, so requiring a recent login there would always be satisfied.
Browser enrollment is not posture-checked. Policies gate the CLI token endpoint. The browser WebAuthn flow (
vouch enroll) issues a session without evaluating device posture, and records a successful login that satisfies the recency and IP policies above. A user who enrolls in the browser can therefore obtain credentials — including via token exchange — from a device your posture policies would reject atvouch login. Treat posture policies as a control on the CLI credential path, not a fleet-wide device gate, until browser enrollment is covered.
If any active policy fails, the token request is rejected with a message naming the failed policy
plus remediation guidance for the user’s operating system. The OAuth error code is access_denied
on the login (FIDO2 assertion) grant and invalid_request on the RFC 8693 token-exchange grant —
RFC 8693 §2.2.2 requires invalid_request for a subject token that is unacceptable based on
policy:
Device posture policy 'Disk Encryption' not satisfied. Enable FileVault in
System Settings > Privacy & Security > FileVault.
The user cannot obtain credentials until they fix the device and retry. There is no override and no grace period.
Three properties are worth understanding before you enable anything:
- No active policies means no enforcement and no posture requirement. The check short-circuits entirely.
- Once any policy is active, a client that sends no posture data is denied. Enabling your first policy therefore also requires every user to be on a CLI version that reports posture.
- Evaluation is fail-closed. An expression that errors at runtime, or returns a non-boolean, counts as a failure, not a pass.
Roll out carefully. Turning on a policy takes effect at the next login for every user in the organization. Announce it, and check what your fleet actually reports first — a policy that looks obviously satisfiable can lock out a whole class of devices.
Preconfigured policies
Seven policies ship built in. Toggle each on or off from /admin/policies.
| Slug | Name | Requires |
|---|---|---|
disk_encryption | Disk Encryption | Full-disk encryption enabled (FileVault, BitLocker, LUKS) |
firewall | Firewall | An active firewall |
screen_lock | Screen Lock | Screen lock on idle enabled |
endpoint_protection | Endpoint Protection | At least one EDR agent installed |
mdm_enrollment | MDM Enrollment | At least one MDM agent detected (Jamf, Kandji, Intune, …) |
platform_integrity | Platform Integrity | Secure Boot enabled |
os_recency | OS Recency | macOS 14.0.0+ or Windows 24H2+. Denies all Linux devices — see below |
Six more policies read the user’s recent authentication history instead of their device:
| Slug | Name | Denies when |
|---|---|---|
issuance_rate_limit | Issuance Rate Limit | The user obtained 10 or more tokens in the past hour |
exchange_rate_limit | Exchange Rate Limit | The user performed 30 or more token exchanges in the past hour (exchange only) |
failed_login_burst | Failed Login Burst | The user had 5 or more failed logins in the past ten minutes |
token_exchange_step_up | Token Exchange Step-Up | No successful hardware login in the past 15 minutes (exchange only) |
exchange_ip_consistency | Exchange IP Consistency | No successful login from this IP address in the past 8 hours (exchange only) |
logout_invalidates_exchange | Logout Invalidates Exchange | The user logged out and has not logged in again (exchange only) |
History comes from the audit log, scoped to the requesting user and the past 24 hours. Two consequences worth knowing: audit retention shorter than two days truncates the window a policy can see (the server warns at startup), and audit writes on the login path are best-effort, so a dropped write can under-count a rate limit by one event.
os_recency is the one with moving parts, and the one to be careful with. It passes a device only
if it is macOS 14.0.0 or later, or Windows 10.0.26100 (24H2) or later.
os_recencydenies every Linux device. The check has no Linux branch, so a Linux client matches neither condition and the policy fails closed. Distributions version independently, so there is no sensible built-in threshold — but the effect is a denial, not an exemption. The user sees: “Linux is not covered by the built-in OS recency check. Your organization may have a custom policy for your distribution.”If any part of your fleet runs Linux, do not enable
os_recency. Write a custom policy that covers all three platforms instead:forbid (principal, action == Vouch::Action::"IssueToken", resource) unless { (context.device.os == "macos" && context.device.os_version_num >= 14000000) || (context.device.os == "windows" && context.device.os_build_num >= 26100) || (context.device.os == "linux" && context.device.os_distribution == "ubuntu" && context.device.os_version_num >= 22004000) };
Those thresholds are compiled into the server, so they advance when you upgrade Vouch. Read the
release notes before upgrading if os_recency is active: a raised floor can lock out devices that
were passing yesterday.
Custom policies
An organization can author up to 20 custom policies and have 10 active at once, alongside any of the built-ins.
The rule builder
“New policy” opens the builder. It asks three things:
- Applies to — token issuance (
vouch login) or token exchange (workload and agent credentials). Device checks are only offered on issuance, because an exchange request carries no device posture; picking exchange switches the builder to activity checks. - Checks — device state (“allow the request only when ALL of these hold”) or recent activity (“deny the request when …”). A rule is one or the other. A device rule may stack several requirements (equivalent to activating them as separate policies, since every active policy must pass); an activity rule carries exactly one condition, following Dogwood’s own guidance that combined history conditions are expressed as separate policies.
- Conditions — one row per condition:
- A device row is field → operator → value. The field dropdown lists every posture attribute
grouped by area, and each field offers only the operators its type allows: booleans get
is, numbers get comparisons, closed-value strings (
os) and sets (edr,mdm) get dropdowns of the values clients can actually report. Version fields take a version like15.3and emit the numericos_version_numencoding for you. - “Add OS version floor” adds the per-platform minimum-version pattern (macOS/Linux by version, Windows by build number) as a single row, OR’d across the platforms you enable.
- An activity row is event → shape → window: happened in the last, did not happen in the last, happened at least N times in the last, or is missing or was followed by another event (e.g. deny when the most recent successful login was followed by a logout — or there was no login at all). The window control enforces the 24-hour history cap.
- A device row is field → operator → value. The field dropdown lists every posture attribute
grouped by area, and each field offers only the operators its type allows: booleans get
is, numbers get comparisons, closed-value strings (
The generated rule previews below the rows, is validated continuously, and for activity rules the validation box states in prose what the rule will deny — since the sample device has no history, a dry-run pass/fail would be meaningless for those.
The builder warns (without blocking) when a successful-login recency condition targets token issuance: the login being evaluated is not yet in the history the rule reads, so “did not happen” locks users out, and “happened” is a once-per-window login cooldown. Login-recency requirements belong on token exchange.
Edit as text is the escape hatch, and a one-way door: it turns the generated rule into an editable textarea, and a policy edited as text reopens as text from then on — the builder never tries to parse hand-written Dogwood back into rows. Copying a built-in also opens as text.
Writing policy text directly
For anything the builder does not cover, write a
Dogwood/Cedar forbid rule. The rule fires — and the
token request is denied — when its unless requirement is not met. Posture attributes live at
context.device.
// Require BitLocker specifically, not just any disk encryption
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.disk_encryption_technology == "bitlocker" };
// Require a recent Ubuntu
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.os_distribution == "ubuntu"
&& context.device.os_version_num >= 22004000 };
// Screen lock must engage within five minutes
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.screen_lock_enabled
&& context.device.screen_lock_idle_timeout_secs <= 300 };
// Require both an EDR agent and MDM enrollment
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.edr_count > 0 && context.device.mdm_count > 0 };
// Apply a rule only on macOS, passing every other platform
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.os != "macos" || context.device.sip_enabled };
That last pattern matters: attributes are populated per platform, so an unqualified rule applies
everywhere. Guard on context.device.os when a requirement is platform-specific.
Writing a history policy
A when temporal { … } clause reads the user’s recent events. Windows are required, capped at 24
hours, and only && and ! are available inside the block (write separate policies for “or”):
// Require a successful login within the last 30 minutes before exchanging tokens
forbid (principal, action == Vouch::Action::"ExchangeToken", resource)
when temporal {
!(formerly within 30m Vouch::Action::"Login"::response{ output.result: true })
};
// Cap SSH certificate issuance at 5 per hour
forbid (principal, action == Vouch::Action::"IssueToken", resource)
when temporal {
exists (n: Long). (
(count_within(1h, Vouch::Action::"IssueCredential"::response{ input.kind: "ssh" })) == n
&& n >= 5
)
};
Aggregations must be compared inside an exists (n: Long). ((count_within(…)) == n && n >= K)
binding — that shape is what lets the count be thresholded.
Event fields
The braces after an event name filter which past events count, by matching these fields. A
literal value selects events (output.result: true means successful logins only); a context
reference requires the field to match the current request (input.ip: context.input.ip, as the
built-in exchange_ip_consistency does). On the decision being evaluated, the same input
fields are readable directly as context.input.*.
| Event | Matchable fields |
|---|---|
Vouch::Action::"Login"::response | input.ip, input.user_agent, output.result (boolean) |
Vouch::Action::"IssueToken"::response | input.ip, input.client_id |
Vouch::Action::"ExchangeToken"::response | input.ip, input.client_id, input.audience |
Vouch::Action::"Logout"::response | none — the event itself is the signal |
Vouch::Action::"RevokeToken"::response | none |
Vouch::Action::"IssueCredential"::response | input.kind — one of "ssh", "aws", "github" |
The same table is generated on /admin/policies under the field reference, from the catalog the
ingestion parity tests check — the in-app copy cannot drift.
The policy editor validates history policies but cannot evaluate them: the test device has no history, so a temporal result is labelled — and, for builder-authored rules, summarized in prose — rather than reported as a plain pass or fail. Verify these against a real account in a staging organization.
Rewriting a CEL policy
CEL expressions were bare booleans; Dogwood policies are forbid rules, and posture attributes
moved from posture.* to context.device.*. A CEL rule that read:
posture.disk_encryption_technology == "bitlocker"
becomes:
forbid (principal, action == Vouch::Action::"IssueToken", resource)
unless { context.device.disk_encryption_technology == "bitlocker" };
Note the inversion: CEL expressions stated what must be true to pass; a forbid … unless rule
states the same requirement, and denies when it is not met. Version comparisons that used
semver(posture.os_version) use the precomputed context.device.os_version_num field.
Available attributes
Every attribute is always present in the evaluation context. When a client does not report one, it
takes a type-appropriate default — false, "", 0, or [] — so an expression never errors on a
missing field. The corollary: a missing attribute looks identical to a negative one. Requiring
context.device.tpm_present == true also fails every client too old to report it.
Booleans (default false)
disk_encryption_enabled, screen_lock_enabled, firewall_enabled, secure_boot_enabled,
sip_enabled, tpm_present, auto_update_enabled, access_control_enforcing, elevated, tty
Strings (default "")
os, os_version, os_distribution, os_build, arch, disk_encryption_technology,
firewall_technology, tpm_version, auto_update_technology, access_control_technology,
parent_process, cli_version, collected_at
Numbers (default 0)
screen_lock_idle_timeout_secs, uptime_secs, edr_count, mdm_count
Derived version numbers (-1 when unparseable)
os_version_num — os_version encoded as major*1000000 + minor*1000 + patch
("15.3.1" → 15003001; 4-component Windows versions encode as -1).
os_build_num — os_build parsed as an integer ("26100" → 26100).
Sets (default empty)
edr, mdm — test membership with context.device.edr.contains("crowdstrike")
The in-app field reference at the bottom of /admin/policies is generated from the same catalog
that drives the builder, with each field’s type and its value on the sample test device.
Version comparison
Compare os_version_num (never the os_version string) — lexical comparison puts "10.0.0"
before "9.0.0", the numeric encoding does not.
context.device.os_version_num >= 14000000
Testing an expression before you enable it
The policy editor validates every rule against sample posture data before you save, using
POST /api/v1/org/policies/validate (the endpoint takes either raw policy_text or a builder
rule, and dry-runs against the decision point the rule targets — an exchange rule is evaluated as
an exchange, not as a login). Use it — a syntactically valid expression that is semantically wrong
fails closed and locks users out.
Test at minimum: a device that should pass, a device that should fail, and a device reporting nothing at all (the “old CLI” case).
Enabling and disabling
Policies have an active flag independent of their existence, so you can stage one and turn it on later, or disable one during an incident without losing it. Toggling takes effect on the next token request; existing sessions are unaffected until they expire.
To roll back an over-strict policy, toggle it off — no restart required.
Audit events
| Event | Trigger |
|---|---|
policy_denied | A policy denied token issuance or exchange |
admin_policy_create | Custom policy created |
admin_policy_update | Custom policy edited |
admin_policy_delete | Custom policy deleted |
admin_policy_toggle | Any policy enabled or disabled |
The four admin_policy_* events are in the never-purged retention class, so the record of when a
policy was created, relaxed, or disabled is permanent.
policy_denied is not. It is an authentication event, retained for
VOUCH_AUTH_EVENTS_RETENTION_DAYS (90 by default) and then purged, so export it before that window
closes if you need denial evidence for longer. See Audit Events.
Troubleshooting
Everyone is denied right after enabling the first policy. Most likely the fleet is on a CLI too old to report posture. Once any policy is active, a request carrying no posture data is rejected. Disable the policy, confirm CLI versions, then re-enable.
A policy fails on devices that visibly satisfy it.
The attribute probably is not reported on that platform and is defaulting to false or "". Check
the server log at RUST_LOG=vouch_server=debug, which logs each policy evaluation and its result,
then guard the expression on context.device.os.
A custom policy never passes. Runtime evaluation errors count as failures (fail-closed), and policies written in CEL syntax for the pre-Dogwood engine always fail. Test the rule in the policy editor against known-good sample posture.
Email Domains
An organization is created from the email domain of its first enrollee. That domain is the organization’s primary domain and cannot be changed. If your users have email addresses on more than one domain — an acquisition, a rebrand, a regional subsidiary — you add those as additional domains so their enrollments attach to the same organization.
Manage them at /admin/domains.
Why verification exists
Claiming a domain determines which organization a user’s enrollment joins, and therefore which
administrators can act on that user. Without proof of ownership, anyone could claim
competitor.com, wait for one of their employees to enroll, and take administrative control of
that account.
So an added domain does nothing until it is verified. Until then it is not indexed and takes no part in matching users at login: users on that domain continue to enroll exactly as if you had never added it.
Adding and verifying a domain
-
Add the domain on
/admin/domains. The server generates a random token and the entry enters thePendingstate. -
Publish the DNS TXT record. Create a TXT record at:
_vouch-verification.<your-domain>with the token shown in the UI as its value. For
example.comthat is_vouch-verification.example.com. -
Click Verify. The server performs a DNS TXT lookup and marks the entry
Verifiedif any record at that name matches the token. If the lookup fails or nothing matches, the entry stays pending and you can retry after fixing DNS.
Once verified, new enrollments from that domain join this organization.
An organization may hold up to 10 additional domains, on top of its primary domain.
Leave the TXT record published. It is not a one-time check — the server re-verifies it periodically, and removing the record will eventually unverify the domain. See below.
Domain states
| State | Meaning | Counts for login matching |
|---|---|---|
| Pending | Added, TXT record never yet observed | No |
| Verified | Ownership confirmed | Yes |
| Unverified | Was verified, then failed re-verification repeatedly | No |
Only Verified entries — plus the primary domain — make up the organization’s owned domain set.
This same set gates SCIM user provisioning: an IdP token can only
create users whose email domain is in it.
Ongoing re-verification
A background task re-checks the DNS TXT record of every verified additional domain. It runs as
part of the general cleanup pass (VOUCH_CLEANUP_INTERVAL, 15 minutes by default), but any
individual domain is re-checked at most once every 24 hours.
After 3 consecutive failures, the entry flips to Unverified:
- New logins stop attaching to your organization for that domain.
- Users who already enrolled keep their organization membership. They are not orphaned, deactivated, or removed. This is deliberate — a DNS outage must not evict your existing users.
- An
org_domain_unverifiedaudit event is recorded.
A single successful check resets the failure counter to zero, so a brief DNS blip costs nothing.
Automatic cleanup
Two garbage-collection rules keep abandoned entries from accumulating:
| Entry | Deleted after | Audit event |
|---|---|---|
Pending — added but never verified | 7 days | org_domain_expired |
Unverified — auto-unverified by failed re-checks | 14 days | org_domain_expired |
Deletion here only removes the claim; it never touches users.
Removing a domain
Remove a domain from /admin/domains. This unclaims it — future enrollments from that domain no
longer join your organization — and records org_domain_removed. Existing users keep their
organization membership, exactly as with unverification.
Audit events
| Event | Trigger |
|---|---|
org_domain_added | Domain added, entering pending |
org_domain_verified | TXT record matched, entry verified |
org_domain_removed | Administrator removed the domain |
org_domain_unverified | Re-verification failed 3 times in a row |
org_domain_expired | Garbage-collected as a stale pending or unverified entry |
See Audit Events for how to browse and retain these.
Troubleshooting
Verify fails but the record looks correct.
Check propagation from the server’s own resolver, not your workstation:
dig +short TXT _vouch-verification.example.com. The value must match the token exactly. Some DNS
providers append the zone name automatically — if you enter _vouch-verification.example.com in a
zone that already appends example.com, you end up with
_vouch-verification.example.com.example.com.
A domain unverified itself. The TXT record was unreachable on 3 consecutive daily checks. Republish it and click Verify again. Existing users on that domain were unaffected.
A domain disappeared from the list. It was pending for more than 7 days, or unverified for more than 14, and was garbage-collected. Re-add it; you will get a fresh token.
SCIM Provisioning
Vouch supports SCIM 2.0 (RFC 7643/7644) for user provisioning and de-provisioning from external identity providers.
Setup
The admin API endpoints (/api/v1/org/*) require an authenticated Vouch session from a user with org admin privileges. The server accepts the access token via Authorization: Bearer <token>, Authorization: DPoP <token>, or the vouch_session cookie.
Prerequisites:
- You must belong to an organization and be an org administrator.
You do not create either by hand. Organizations are created automatically the first time someone enrolls from a given email domain, and that first enrollee becomes the organization’s administrator. Every administrator after that is promoted from the admin UI. See Organizations and Administrators for the full model.
1. Create a SCIM token
The simplest way is the admin UI: go to /admin/scim-tokens, create a token, and copy it.
Choose an expiry between 1 and 365 days.
To script it instead, call the API with an access token from an admin session:
curl -X POST https://auth.example.com/api/v1/org/scim-tokens \
-H "Authorization: Bearer $(vouch credential token)" \
-H "Content-Type: application/json" \
-d '{"description": "SCIM integration", "expires_in_days": 90}'
Either way the token is prefixed vouch_scim_ and is shown once. It is stored only as a
SHA-256 hash, so it cannot be recovered — if you lose it, revoke it and create another.
2. Configure your IdP
Enter the following in your IdP’s SCIM configuration:
- SCIM endpoint URL:
https://auth.example.com/scim/v2/ - Bearer token: the
vouch_scim_...token from step 1
Domain validation
POST /scim/v2/Users requires userName to be an email address, or emails[] to supply one —
Vouch keys users by email, so a value that is neither is rejected with 400 and
"userName must be an email address".
The email’s domain must also be one the token’s organization has proven it owns: the
organization’s primary domain, or an additional domain that has completed DNS TXT verification
(see Email Domains). A push for any other domain — including a domain that is
merely added but not yet verified, or a subdomain of a verified one — is rejected with 400 and
"scimType": "invalidValue".
This closes an isolation gap rather than adding a new setup step: it matters whenever your IdP
pushes a user whose address isn’t already on the org’s own domain — provisioning from a second
email domain, or a misconfigured IdP pointed at the wrong tenant. If you provision from more than
one domain, verify each one first at /admin/domains before pushing users on it.
3. Manage tokens
List and revoke tokens at /admin/scim-tokens, or through the API:
# List active SCIM tokens
curl -H "Authorization: Bearer $(vouch credential token)" \
https://auth.example.com/api/v1/org/scim-tokens
# Revoke a SCIM token
curl -X DELETE -H "Authorization: Bearer $(vouch credential token)" \
https://auth.example.com/api/v1/org/scim-tokens/<token-id>
Revocation takes effect immediately — tokens are checked against the database on every request. Expired tokens are removed by the background cleanup task.
Attribute Updates (PATCH)
PATCH /scim/v2/Users/{id} and PATCH /scim/v2/Groups/{id} accept add, replace, and remove
operations. Vouch stores a subset of the SCIM schema, and all three operations behave the same way
across it:
| Resource | Attribute path | add / replace | remove |
|---|---|---|---|
| User | active | sets it; a non-boolean is rejected | rejected (mutability) |
| User | name.formatted, displayName | sets the stored name | clears it |
| User | externalId | sets it | clears it |
| User | userName, emails (including emails[type eq "work"].value) | accepted only when the value is the user’s stored email; any other value is rejected (mutability) | rejected (mutability) |
| Group | displayName | sets it; empty is rejected | rejected (mutability) |
| Group | externalId | sets it | clears it |
| Group | members | add adds members; replace swaps the whole set | removes every member |
| Group | members[value eq "<user-id>"] | replace swaps that member for the one in value; add is rejected (invalidPath) | removes that member |
Paths may carry the core schema URN (urn:ietf:params:scim:schemas:core:2.0:User:userName), and
attribute names are matched case-insensitively, as RFC 7644 requires. List filters accept the same
qualified names.
An operation with no path — a value object such as {"op": "replace", "value": {"active": false}} —
sets every attribute in the table the object carries, Group members included.
Any other attribute path is ignored, and the request still returns 200. Okta and Entra push
attributes Vouch does not store (title, department, name.givenName, enterprise extensions);
rejecting those would fail an entire provisioning sync over data the directory never keeps. The
consequence for you: a 200 does not by itself prove Vouch stored what the IdP sent. The response
body is the resource as stored — check it when an attribute appears not to sync.
userName and emails are advertised as immutable in /scim/v2/Schemas: Vouch keys a user by
their email and cannot change it. An IdP that sends the unchanged address on a routine sync succeeds
(the comparison ignores case); one that sends a different address, removes it, or clears emails
gets 400 with "scimType": "mutability". If a user’s email changes at the IdP, de-provision and
re-provision them.
active is advertised as required. A user has no state without it, so removing it returns 400
mutability rather than silently changing the user’s access; RFC 7644 gives removing a required
attribute that error, and it applies to Group displayName the same way.
A remove of members with no filter and no value list empties the group, as RFC 7644
defines it. Entra’s form — path: "members" with the members to drop in value — removes only
those members.
Other requests rejected with 400:
| Request | scimType |
|---|---|
remove with no path | noTarget |
replace of members[value eq "<user-id>"] when that user is not a member | noTarget |
A members filter other than value eq "<user-id>" | invalidFilter |
add or replace with no value, or a member entry without a string value | invalidValue |
A PATCH is all or nothing. Every operation is applied to the stored resource in order and the
result — attributes and membership together — is written in one transaction, so a 400 or 500
means nothing in the request was applied. A request that changes nothing (re-adding a current
member, re-sending the current externalId) writes nothing and leaves meta.lastModified as it
was.
Setting active to false is the one attribute update with effects beyond the record: it
invalidates the user’s sessions, revokes their SSH certificates, and clears their GitHub refresh
token, the same way de-provisioning does. Those revocations run before the record is written, so a
deactivation whose write then fails has still revoked access; retrying the request completes it.
Replacing Resources (PUT)
PUT /scim/v2/Users/{id} and PUT /scim/v2/Groups/{id} replace the resource with the body and
return 200 with the stored resource. PUT never creates: an id that does not exist returns 404.
Attributes the body leaves out are cleared, as RFC 7644 §3.5.1 permits:
| Resource | Attribute | Present | Omitted |
|---|---|---|---|
| User | userName | must be the stored email, or 400 mutability | 400 invalidSyntax (required) |
| User | emails | every value must be the stored email, or 400 mutability | left as is |
| User | name | stored (formatted, or givenName and familyName joined) | cleared |
| User | externalId | stored | cleared |
| User | active | stored | set to true |
| Group | displayName | stored; empty is 400 invalidValue | 400 invalidSyntax (required) |
| Group | externalId | stored | cleared |
| Group | members | replaces the whole member set | every member is removed |
id, meta, and schemas in the body are ignored.
Two rows change access, so check your IdP sends them:
- A User PUT without
activemakes the user active. A PUT with"active": falsedeactivates the user with the same effects as the PATCH described above, including the refusal to deactivate the organization’s last active admin. - A Group PUT without
membersempties the group: a PUT states the whole resource, so an absent member list means none.
Error Responses
Every error from /scim/v2/* has the RFC 7644 §3.12 JSON body
("schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], status as a string), including
rejections that happen before the request reaches Vouch’s SCIM logic:
| Cause | Status | scimType |
|---|---|---|
| Body is not valid JSON | 400 | invalidSyntax |
Body omits a required attribute (userName, Group displayName) | 400 | invalidSyntax |
| Body is JSON but an attribute has the wrong type or an empty required value | 400 | invalidValue |
Query parameter has the wrong type (startIndex=abc) | 400 | invalidValue |
Content-Type is not JSON (application/scim+json and application/json both work) | 415 | — |
| Body over 64 KiB | 413 | — |
| Resource id that does not exist, including one that is not a UUID | 404 | — |
| Path that is not a SCIM endpoint | 404 | — |
Method the endpoint does not support (Allow lists the supported ones) | 405 | — |
Rate limit exceeded (Retry-After is set) | 429 | — |
SCIM and the /api/v1/org/* API share one rate-limit bucket per client IP (20 requests burst,
1 per second).
De-Provisioning Behavior
When a user is de-provisioned via SCIM (e.g., employee leaves the organization):
| Action | Timing | Effect |
|---|---|---|
| Active sessions invalidated | Immediate | All current sessions for the user are terminated |
| SSH certificates revoked | Immediate | All issued SSH certificates are marked as revoked |
| Enrolled authenticators deleted | Immediate | All registered credentials are removed (cascade) |
| User record deleted | Immediate | User cannot re-enroll or authenticate |
| Audit event logged | Immediate | De-provisioning recorded with SCIM token info |
Nothing waits for session expiry: access ends when the IdP sends the delete.
SCIM Endpoint Authentication
SCIM endpoints require bearer token authentication:
Endpoint: every /scim/v2/* route — Users and Groups, with GET, POST, PUT, PATCH, and DELETE.
Authentication:
- Bearer token in the
Authorizationheader - Token created in the admin UI or via
POST /api/v1/org/scim-tokens - Expiry is operator-chosen at creation, between 1 and 365 days
- Use a separate token per IdP integration, so one can be revoked without disturbing the others
# Example SCIM request
curl -X DELETE https://auth.example.com/scim/v2/Users/0192f1a8-7c3e-7d4a-9b2e-5f6a7b8c9d0e \
-H "Authorization: Bearer vouch_scim_..." \
-H "Content-Type: application/scim+json"
Token Security:
- Tokens are hashed (SHA-256) before storage
- Shown once at creation, never retrievable after
- Bound to specific organization
- Minimum 256 bits of entropy
Concurrent Provisioning
User creation validates domain ownership inside a transaction keyed on the
organization record, so heavy concurrent provisioning (an IdP bulk-syncing
many users at once) or simultaneous domain changes can occasionally collide.
When the server exhausts its internal retries it responds with
503 Service Unavailable and a Retry-After header: this is transient
backpressure, not a fault. Okta and Entra retry such
responses automatically; no operator action is needed unless 503s persist,
which indicates sustained contention on the organization (for example, a
domain-management script running during a bulk sync).
Group writes behave the same way per group: two PATCH or PUT requests for one
group at the same moment collide on the group record, and one retries against
the other’s result so neither loses the other’s member changes. Exhausted
retries return the same 503 with Retry-After.
SCIM Audit Logging
All SCIM operations are logged for compliance and security monitoring:
| Operation | Resource Type | Logged Data |
|---|---|---|
create | User | resource_id, scim_token_id, timestamp |
update | User | resource_id, scim_token_id, timestamp (PATCH) |
replace | User | resource_id, scim_token_id, timestamp (PUT) |
delete | User | resource_id, scim_token_id, timestamp |
create | Group | resource_id, display_name, scim_token_id, timestamp |
update | Group | resource_id, scim_token_id, timestamp (PATCH) |
replace | Group | resource_id, scim_token_id, timestamp (PUT) |
delete | Group | resource_id, scim_token_id, timestamp |
SCIM vs Manual Enrollment
| Aspect | SCIM Provisioning | Manual Enrollment |
|---|---|---|
| User record creation | IdP pushes user info | User initiates enrollment |
| Hardware enrollment | Still requires physical hardware key | Requires physical hardware key |
| De-provisioning | Immediate via IdP (user deleted, sessions invalidated, certs revoked) | Manual admin action (sessions invalidated, certs revoked) |
| Group membership | Synced from IdP | Not available outside SCIM |
Note: SCIM pre-provisioning creates a user record, but they still cannot authenticate until they physically enroll a hardware FIDO2 authenticator. The security model remains: no credential without hardware.
Audit Events
Every authentication, credential issuance, and administrative action is recorded as an audit
event. Events are stored in the audit_events table and browsable at /admin/audit.
Email addresses are masked to domain only, with an HMAC column alongside so you can correlate a user’s activity without the log itself holding their address. Events are enriched with the country code, ASN, and network organization resolved from the client IP.
The GeoIP databases are compiled into the server binary. They cannot be refreshed independently — new geolocation data arrives with a new Vouch release.
Audit Events
Authentication and key lifecycle
| Event Type | Description |
|---|---|
login_success | User authenticated — FIDO2 passkey login, or a returning user signing in on the website via the upstream IdP (the latter has no authenticator_id) |
login_failed | Failed authentication attempt |
enrollment | User enrolled their first hardware key |
logout | User logged out (including RFC 7009 token revocation) |
key_registered | Additional hardware key registered (vouch register) |
key_removed | Hardware key removed |
key_renamed | Hardware key renamed |
device_auth_approved | Browser approved a CLI device-authorization request |
key_registration_replay | Replayed key-registration link rejected (possible attack) |
identity_bound | Upstream IdP identity (issuer + subject) bound to an account on its first IdP login; data.idp_issuer names the issuer |
identity_bind_refused | IdP sign-in refused: the asserted email matched an account already bound to a different subject at the same issuer (possible upstream email reassignment); data.idp_issuer names the issuer |
Credential issuance
| Event Type | Description |
|---|---|
ssh_credential | SSH certificate issued; data includes the serial, principals, requesting agent, and expiry |
aws_credential | AWS OIDC token issued; data includes the pinned IAM role_arn (the https://aws.amazon.com/roles claim), the requesting agent, and token expiry |
github_credential | GitHub installation token issued or installation connected; data includes repositories and permissions |
token_exchange | RFC 8693 token exchange (workload identity federation); data includes the client, audience, scope, and issued token type |
OAuth clients
| Event Type | Description |
|---|---|
oauth_token_issued | Token issued at /oauth/token (data.details carries the grant type) |
oauth_token_revoked | All tokens for an application revoked |
oauth_client_registered | OAuth client registered (RFC 7591 or applications UI) |
oauth_client_updated | OAuth client configuration updated |
oauth_client_deleted | OAuth client deleted |
oauth_secret_added | Client secret added |
oauth_secret_revoked | Client secret revoked |
Administration and organization
| Event Type | Description |
|---|---|
scim_operation | SCIM provisioning operation (data carries operation and resource type) |
admin_promote | Org-admin role granted |
admin_demote | Org-admin role removed |
admin_deactivate | User account deactivated |
admin_activate | User account reactivated |
admin_revoke_credentials | Admin revoked a member’s keys, sessions, and certificates |
admin_remove_user | Admin removed a member from the organization |
policy_denied | A posture or temporal policy denied credential issuance |
admin_policy_toggle | Posture policy enabled or disabled |
admin_policy_create | Custom posture policy created |
admin_policy_update | Custom posture policy updated |
admin_policy_delete | Custom posture policy deleted |
admin_create_scim_token | SCIM API token created |
admin_delete_scim_token | SCIM API token deleted |
admin_revoke_scim_token | SCIM API token revoked |
org_domain_added | Additional email domain added to the organization |
org_domain_verified | Additional email domain ownership verified |
org_domain_removed | Additional email domain removed by an admin |
org_domain_expired | Stale additional domain removed by the cleanup task (never verified, or unverified past its TTL) |
org_domain_unverified | Verified additional domain flipped to unverified after repeated DNS re-check failures |
org_subdomain_claimed | Issuer subdomain claimed for the organization |
org_subdomain_released | Issuer subdomain released (by an admin, or automatically when its backing domain became unverified) |
org_issuer_key_rotated | Per-org issuer signing keys rotated (one event per algorithm) |
org_issuer_key_revoked | Per-org previous signing keys revoked (one event per algorithm) |
org_issuer_key_emergency_rotation | Emergency rotation of per-org issuer keys (one event per algorithm) |
Retention
Events fall into three retention classes. The class is a property of the event type; it is not configurable.
| Class | Governed by | Contains |
|---|---|---|
| Authentication | VOUCH_AUTH_EVENTS_RETENTION_DAYS (default 90) | Logins, enrollment, logout, key and device-auth lifecycle, SCIM operations |
| OAuth and credentials | VOUCH_OAUTH_EVENTS_RETENTION_DAYS (default 90) | Credential issuance, token issue/revoke, client registration — the high-volume events |
| Kept forever | nothing — never deleted | Every administrative action, OAuth client and secret lifecycle, and all organization domain, subdomain, and issuer-key events |
The third class is the one to know about. Administrative and organization-lifecycle records are never purged by the cleanup task, regardless of how you set the two retention variables. That is deliberate: these are the records that answer “who granted this person admin, and when”, and they are low-volume enough to keep indefinitely. Plan database growth accordingly, and if a regulation requires you to delete them, that is a manual database operation.
# Keep authentication events for two years, credential events for 90 days.
VOUCH_AUTH_EVENTS_RETENTION_DAYS=730
VOUCH_OAUTH_EVENTS_RETENTION_DAYS=90
Expired events are removed by the background cleanup task, which runs every
VOUCH_CLEANUP_INTERVAL minutes (default 15). Setting the interval to 0 disables cleanup
entirely, and events then accumulate without bound.
Retention values must not be negative. The server rejects a negative value at startup, because a negative window produces a cutoff in the future — which would delete the entire audit log on the first cleanup pass.
Browsing and exporting
/admin/audit provides a paginated view scoped to your organization, with filters for event
type, user ID, email, and a date range.
For programmatic access — SIEM ingestion, backfills, ad hoc scripting — use the audit events
API described below. The raw audit_events table is still available as an operator escape
hatch:
# SQLite
sqlite3 /data/vouch.db \
"SELECT * FROM audit_events WHERE created_at > datetime('now', '-1 day');"
# PostgreSQL
psql "$VOUCH_DATABASE_URL" -c \
"SELECT * FROM audit_events WHERE created_at > now() - interval '1 day';"
Application logs are separate from audit events and go to stdout — see
Monitoring and Metrics for structured logging and the
x-fapi-interaction-id correlation header.
Audit Events API
GET /api/v1/org/audit-events returns audit events scoped to your organization (the primary
domain plus any verified additional domain) in ID order.
Authentication
Two auth methods are accepted; cookie (browser session) auth is rejected outright, since this endpoint is meant for unattended pollers as much as interactive use:
- Org API token with the
audit:readscope — the token type used for SCIM provisioning, generalized to carry additional scopes. Mint one on/admin/scim-tokens(check “Also grant read-only audit log access”) or viaPOST /api/v1/org/scim-tokenswith"audit_read": true. A token minted without that option (or before this feature existed) is rejected with 403. - Org-admin user session — a FIDO2-authenticated org admin’s access token (
Authorization: BearerorDPoP), the same credential used for the other/api/v1/org/*endpoints.
curl -H "Authorization: Bearer $VOUCH_AUDIT_TOKEN" \
"https://vouch.example.com/api/v1/org/audit-events"
Filters
| Parameter | Description |
|---|---|
event_type | Comma-separated list of event types (e.g. login_success,login_failed). Unknown or empty values return 400 rather than silently matching nothing. |
user_id | Exact match. |
email | Exact match (HMAC lookup, case-insensitive). |
since / until | RFC 3339 timestamps; only events strictly after since and strictly before until. |
after | Forward cursor: the id of the last event from a previous page. Returns events in ascending (oldest-first) order — the shape a poller wants. Takes precedence over before. |
before | Backward cursor: the id of the last event from a previous page. Returns events in descending (newest-first) order, matching /admin/audit. |
limit | Page size, default 500, maximum 1000. |
format | ocsf to project events into OCSF (see below); omitted for native JSON. |
With neither after nor before set, the first call defaults to an ascending walk from the
start of retained history — a poller with no saved cursor yet can call the endpoint with no
parameters and just start following next_cursor forward. Pass before explicitly to browse
backward from the newest event instead.
Response
Default response is a JSON envelope:
{
"events": [
{
"id": "01920000-...",
"event_type": "login_success",
"user_id": "01910000-...",
"email_domain": "example.com",
"email_hmac": "9f86d0...",
"created_at": "2026-01-01T00:00:03.512Z",
"data": { "authenticator_id": "..." }
}
],
"next_cursor": "01920000-..."
}
email_hmac is included — it is the documented correlation key for tying events to a specific
user without storing their address in the log (see “Email masking” above), and is already
org-scoped.
Cursor semantics and delivery guarantee
next_cursor is present whenever there may be more matching events; pass it back as after
(or before, if you’re walking backward) to continue. IDs are UUID v7 (time-ordered) and
every event is written before the request that caused it receives its response, but
concurrent requests can still commit in a different order than they minted IDs — a naive
high-water-mark poller that just tracks “the highest ID seen” can miss an event that commits
a moment after a higher ID from an overlapping request.
The API’s delivery guarantee instead of ID ordering: an event is never returned with
created_at newer than now - 30s, regardless of the until you pass. A poller that
requests after=<last cursor> no more often than every 30 seconds, and persists the returned
next_cursor after each successful page, will not miss events that commit within that 30-second
window. Because pages can be byte-capped (see NDJSON below), always follow next_cursor until a
page comes back without one rather than assuming one poll drains everything new.
An event’s ID and created_at are stamped together immediately before its insert, and the
insert completes before the response is sent, so a committed event’s timestamp trails its
commit only by the write itself. Audit writes are best-effort, however: a write that fails
outright is logged server-side and not retried, so the event is absent rather than late —
treat the guarantee as best-effort under write-path failure rather than a hard real-time
bound.
NDJSON
Send Accept: application/x-ndjson for one JSON object per line instead of the envelope.
Useful for streaming into a poller that appends to a file or pipes into jq. Responses are
buffered server-side and capped at 5 MiB; if a page would exceed that, the response stops at
the last complete line and a Link: <...>; rel="next" header carries the cursor for the rest
— always follow it the same way you’d follow next_cursor in the JSON envelope.
curl -H "Authorization: Bearer $VOUCH_AUDIT_TOKEN" \
-H "Accept: application/x-ndjson" \
"https://vouch.example.com/api/v1/org/audit-events" | jq -c .
SIEM poller examples
Microsoft Sentinel (Codeless Connector Framework RestApiPoller): poll on an interval,
carry next_cursor forward as the after query parameter between polls, and treat the 30s
lag window as the platform’s ingestion delay tolerance.
Splunk / Elastic (generic HTTP poll): configure a REST/HTTP input against
GET /api/v1/org/audit-events?format=ocsf with the bearer token, checkpoint on the response’s
next_cursor, and poll no more frequently than every 30 seconds.
Reads are not audited
Polling this endpoint does not itself write an audit event — that would create a feedback loop
of one event per poll. Reads are logged to the application’s structured log (tracing::info!,
token or user ID, event count) instead.
OCSF Mapping
?format=ocsf projects each event into OCSF 1.9.0, mapping Vouch’s ~40
event types onto four Identity & Access Management classes. Native JSON stays the canonical,
lossless representation — this is a projection for SIEM ingestion, and every field Vouch
records is still present in data.
status_id is Success unless the event type is itself a failure (login_failed, for
example) or data carries a top-level refusal member. admin_remove_user, admin_deactivate,
and a scim_operation delete or deactivating update record "refusal": "last_admin" when
removing the organization’s last active admin was refused after the member’s sessions and
certificates had already been revoked; those rows export with status_id Failure. SCIM rows
written by v2026.9.4 carry the refusal inside details instead and export as Success.
Seven event types map to OCSF activity_id: 99 (“Other”) because the OCSF IAM classes have no
predefined activity for them. Per the OCSF 1.9.0 spec, when activity_id is 99 the
activity_name attribute must carry a source-specific label (not the literal “Other”), so
each of these events emits a distinct activity_name and also preserves the original Vouch
event_type in unmapped.event_type for cross-product correlation:
| Event Type | OCSF Class | activity_id | activity_name |
|---|---|---|---|
admin_promote | Account Change (3001) | 99 | Admin Promote |
admin_demote | Account Change (3001) | 99 | Admin Demote |
admin_revoke_credentials | Account Change (3001) | 99 | Admin Revoke Credentials |
identity_bound | Account Change (3001) | 99 | Identity Bound |
key_renamed | Account Change (3001) | 99 | Key Renamed |
oauth_token_revoked | Authorize Session (3003) | 99 | OAuth Token Revoked |
scim_operation | Entity Management (3004) | 99 | SCIM Operation |
| Event Type | OCSF Class UID | OCSF Class Name |
|---|---|---|
login_success | 3002 | Authentication |
login_failed | 3002 | Authentication |
logout | 3002 | Authentication |
device_auth_approved | 3002 | Authentication |
identity_bind_refused | 3002 | Authentication |
enrollment | 3001 | Account Change |
identity_bound | 3001 | Account Change |
key_registered | 3001 | Account Change |
key_removed | 3001 | Account Change |
key_renamed | 3001 | Account Change |
key_registration_replay | 3001 | Account Change |
admin_promote | 3001 | Account Change |
admin_demote | 3001 | Account Change |
admin_activate | 3001 | Account Change |
admin_deactivate | 3001 | Account Change |
admin_revoke_credentials | 3001 | Account Change |
admin_remove_user | 3001 | Account Change |
ssh_credential | 3003 | Authorize Session |
aws_credential | 3003 | Authorize Session |
github_credential | 3003 | Authorize Session |
token_exchange | 3003 | Authorize Session |
oauth_token_issued | 3003 | Authorize Session |
oauth_token_revoked | 3003 | Authorize Session |
scim_operation | 3004 | Entity Management |
oauth_client_registered | 3004 | Entity Management |
oauth_client_updated | 3004 | Entity Management |
oauth_client_deleted | 3004 | Entity Management |
oauth_secret_added | 3004 | Entity Management |
oauth_secret_revoked | 3004 | Entity Management |
policy_denied | 3002 | Authentication |
admin_policy_toggle | 3004 | Entity Management |
admin_policy_create | 3004 | Entity Management |
admin_policy_update | 3004 | Entity Management |
admin_policy_delete | 3004 | Entity Management |
admin_create_scim_token | 3004 | Entity Management |
admin_delete_scim_token | 3004 | Entity Management |
admin_revoke_scim_token | 3004 | Entity Management |
org_domain_added | 3004 | Entity Management |
org_domain_verified | 3004 | Entity Management |
org_domain_removed | 3004 | Entity Management |
org_domain_expired | 3004 | Entity Management |
org_domain_unverified | 3004 | Entity Management |
org_subdomain_claimed | 3004 | Entity Management |
org_subdomain_released | 3004 | Entity Management |
org_issuer_key_rotated | 3004 | Entity Management |
org_issuer_key_revoked | 3004 | Entity Management |
org_issuer_key_emergency_rotation | 3004 | Entity Management |
An event type this server doesn’t recognize (a newer kind an older binary doesn’t know about
yet) is emitted as an OCSF Base Event (class_uid: 0) with the raw type preserved in
unmapped.event_type, never a 500.
This table and the mapping code are kept in sync by an automated test
(ocsf_class in handlers/api/org/ocsf.rs) that fails the build if they drift apart.
Known gap: events written before the NULL-domain fix
Org scoping (both /admin/audit and the API) filters by email_domain. Four write sites used
to insert SCIM and org-lifecycle cleanup events with a NULL email_domain (they act on
behalf of an organization rather than a specific user, so had no email to derive a domain
from). Events written by those code paths before the fix landed remain invisible to
org-scoped reads — there is no backfill migration, since the org that wrote them is only
recoverable from application logs, not the row itself. Events written after the fix carry the
org’s primary domain and are visible normally.
Monitoring and Metrics
Health endpoints
Vouch exposes two health endpoints. They answer different questions and are not interchangeable.
| Endpoint | Checks | Success | Failure |
|---|---|---|---|
GET /health | Nothing — the process is running | 200, body ok (plain text, not JSON) | Only fails if the process is hung or dead |
GET /health/ready | Database connectivity (SELECT 1) | 200 {"status":"ready"} | 503 {"status":"not_ready","reason":"database"} |
Neither requires authentication. Both are reachable over plain HTTP on port 80 when TLS is configured, so a load balancer can health-check without TLS.
curl -s https://auth.example.com/health
# ok
curl -s https://auth.example.com/health/ready
# {"status":"ready"}
Use the right one for the right probe.
/healthis a liveness probe: it tells you whether to restart the process./health/readyis a readiness probe: it tells you whether to send traffic. Pointing a readiness probe or a load balancer target group at/healthmeans an instance whose database has failed keeps reporting healthy and keeps receiving requests.
Other unauthenticated endpoints useful for synthetic checks:
| Endpoint | Confirms |
|---|---|
/.well-known/openid-configuration | The OIDC provider is serving discovery |
/.well-known/oauth-protected-resource | RFC 9728 protected-resource metadata |
/oauth/jwks | Signing keys are loaded and published |
/v1/credentials/ssh/ca | The SSH CA is loaded |
Prometheus metrics
Vouch exposes Prometheus metrics at GET /metrics, but only when you set a bearer token:
VOUCH_METRICS_BEARER_TOKEN=<a long random string>
If the variable is unset the endpoint is not registered at all, and the startup log says
Prometheus /metrics endpoint disabled (VOUCH_METRICS_BEARER_TOKEN not set). There is no
unauthenticated mode.
Scrape it with the token in an Authorization: Bearer header; the comparison is constant-time and
anything else returns 401.
# prometheus.yml
scrape_configs:
- job_name: vouch
scheme: https
authorization:
credentials: <the same token>
static_configs:
- targets: ["auth.example.com"]
/metrics is not rate-limited, but it is subject to the global 30-second request timeout.
Exported metrics
| Metric | Type | Labels | Meaning |
|---|---|---|---|
http_requests_total | counter | method, path, status | Requests served. See the label note below. |
http_request_duration_seconds | histogram | method, path | Request latency. Not labelled by status. |
vouch_auth_events_total | counter | event_type | Authentication outcomes |
vouch_credential_issuance_total | counter | type | Credentials issued |
The method and path labels are both drawn from fixed sets, so the number of series has a
ceiling that no request can raise:
pathis the matched route template (e.g./v1/keys/{id}), never the raw request target. A request that matches no route is labelled<unmatched>rather than by the target it asked for, so 404 traffic contributes exactly one series per method/status pair. To see what is actually being probed, read the request logs — every request logs its raw path on therequestspan.methodis one of the nine methods registered by RFC 9110. Any other token — HTTP permits any token as a method — is labelledOTHER.
vouch_auth_events_total uses these event_type values: enrollment, browser_login_success,
fido2_login_success, fido2_login_failure, authorization_code_success.
vouch_credential_issuance_total uses these type values: ssh, aws, github, oidc.
The metrics carry no
HELPorTYPEdescriptions in the scrape output. This page is the reference for what they mean.
There are no gauges, and no metrics for database pool saturation, cleanup runs, or rate-limit
rejections specifically. Use http_requests_total{status="429"} to observe rate limiting, and your
database’s own monitoring for pool health.
Useful queries
# Login failure rate
rate(vouch_auth_events_total{event_type="fido2_login_failure"}[5m])
# Credential issuance by type
sum by (type) (rate(vouch_credential_issuance_total[5m]))
# 95th percentile latency by route
histogram_quantile(0.95,
sum by (le, path) (rate(http_request_duration_seconds_bucket[5m])))
# Rate-limited requests
sum(rate(http_requests_total{status="429"}[5m]))
Logging
Vouch logs to stdout using tracing. Two settings control it.
Format — VOUCH_LOG_FORMAT accepts text (default) or json. Use json for anything that
ships logs to an aggregator; the server rejects any other value at startup.
VOUCH_LOG_FORMAT=json
Level — RUST_LOG takes a standard EnvFilter directive, defaulting to info.
RUST_LOG=info # normal operation
RUST_LOG=warn # quiet
RUST_LOG=debug # troubleshooting; verbose
RUST_LOG=info,vouch_server=debug # debug just Vouch
RUST_LOG=vouch_server=debug,tower_http=info
Security-relevant conditions are logged to a security target — certification test mode being
active, a loopback rp_id combined with TLS, and rejected Host headers on the redirect
listener.
Correlating requests
Every request is assigned an interaction ID, exposed and accepted as the FAPI header
x-fapi-interaction-id — not x-request-id. If a client supplies one it is propagated;
otherwise the server generates a UUIDv7. It is attached to every log line and trace span emitted
while handling that request, so it is the field to search on when tracing one user’s problem.
Log it at your load balancer too, and a report of “my login failed at 14:32” becomes a single grep.
Distributed tracing
The server exports OpenTelemetry spans over OTLP/gRPC when you point it at a collector:
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_SERVICE_NAME=vouch-server # default: vouch-server
When OTEL_EXPORTER_OTLP_ENDPOINT is unset, tracing export is disabled entirely and costs nothing.
Spans are batched and flushed on graceful shutdown.
If the endpoint is set but the exporter cannot be built, the server fails to start — a misconfigured collector address is a startup error, not a silent degradation.
Alerting
| Condition | Severity | Why |
|---|---|---|
/health non-200 or unreachable | Critical | The process is down |
/health/ready returning 503 | Critical | The database is unreachable; the instance can serve nothing |
Sustained rise in fido2_login_failure | Warning | Possible credential stuffing, or a broken IdP integration |
http_requests_total{status="429"} climbing | Warning | Rate limiting is biting. If it started right after a load balancer change, check VOUCH_TRUSTED_PROXIES — limits key on client IP, and an unconfigured proxy makes every user share one bucket |
/v1/credentials/ssh/ca not returning a key | Warning | The SSH CA is not loaded; SSH certificates cannot be issued |
| Database size growth | Warning | Administrative audit events are never purged — see Audit Events |
| TLS certificate near expiry | Warning | Vouch reloads certificates but does not renew them |
Audit events
Authentication, credential issuance, and administrative events are recorded separately from
application logs, in the database, and browsable at /admin/audit. See
Audit Events for the full catalogue and retention behavior.
Security Hardening
Vouch ships with secure defaults, so most of this page is describing behavior you get for free — worth knowing because it shapes what you will see in logs and support tickets. Two sections describe controls you must opt into: authenticator policy and trusted proxies.
Authenticator policy
Vouch only issues credentials to a hardware security key it can prove is genuine, and there is no setting that relaxes that. Every registration must satisfy both checks below; the only thing you configure is whether to narrow it further to specific models.
Attestation format. Only packed and fido-u2f are accepted. Everything else is rejected at
registration with a 400 — none (software authenticators and browser-synced passkeys), the
platform formats tpm, apple, android-key and android-safetynet (Windows Hello, Touch ID,
Android), and any identifier not on that list. Format identifiers are matched case-sensitively, so
Packed is not packed.
Attestation certificate. The authenticator must present an x5c chain that validates against
a pinned Yubico root. Self-attestation is rejected, and so is a chain that is present but does not
verify — a self-signed certificate offered in x5c is refused exactly like no certificate at all.
This is what makes the hardware_verified claim in issued tokens a statement Vouch can support,
so it is not configurable.
The practical consequence: Vouch enrolls YubiKeys. Authenticators from other vendors chain to their own vendor roots, which are not pinned, and are rejected at registration.
Restricting which authenticator models may enroll
# Any authenticator with a valid attestation chain (default)
VOUCH_ALLOWED_AAGUIDS=
# Only FIPS-certified YubiKey models
VOUCH_ALLOWED_AAGUIDS=fips-only
# Any YubiKey 5 series model, including FIPS, Enterprise, and Bio Multi-protocol
VOUCH_ALLOWED_AAGUIDS=yubikey-5
# An explicit allowlist of AAGUIDs
VOUCH_ALLOWED_AAGUIDS=cb69481e-8ff7-4039-93ec-0a2729a154a8,d8522d9f-575b-4866-88a9-ba99fa02f35b
The AAGUID identifies an authenticator model, not an individual device. The two keywords are
maintained lists: fips-only matches FIPS-certified YubiKeys, yubikey-5 matches the YubiKey 5
series (excluding the Security Key series and Bio FIDO Edition). Anything else is parsed as a
comma-separated list of AAGUID UUIDs, and a malformed entry is a fatal startup error.
The AAGUID is read from the id-fido-gen-ce-aaguid extension of the verified attestation
certificate, never from the client-supplied authData. A chain that validates but carries no such
extension proves the key is genuine without saying which model it is, so it yields no AAGUID and
is rejected whenever a policy is configured.
If your organization has a contractual FIPS requirement for the authenticator itself, fips-only
is the control that enforces it. Nothing else in Vouch does.
Restricting AAGUIDs affects enrollment. Users who already enrolled a now-disallowed model keep working; tighten the policy before rolling out keys, not after.
What the certificate is checked against
The leaf is checked against the certificate requirements in WebAuthn Level 2 section 8.2.1, in addition to the chain terminating at a pinned Yubico root:
- the certificate is X.509 version 3;
- if it carries a Basic Constraints extension,
cAis false; - if it carries the
id-fido-gen-ce-aaguidextension, that extension is not marked critical and its value is the AAGUID wrapped in two OCTET STRINGs.
A malformed AAGUID extension fails the registration rather than being skipped. When authData
also carries an AAGUID, the two are cross-checked and a disagreement fails the registration. There
is no setting for any of this.
Rate limiting
Three tiers, applied per resolved client IP using a GCRA limiter. The limits are compile-time constants; there is no environment variable to tune them.
| Tier | Burst | Sustained | Applies to |
|---|---|---|---|
| Authentication | 8 | 1 per 2s | /oauth/token, /oauth/par, /oauth/fido2/challenge, /oauth/device, /oauth/register*, /v1/keys/register/*, /login/webauthn/*, /enroll/webauthn/* |
| Credential issuance | 15 | 1 per 2s | /v1/credentials/ssh, /v1/credentials/aws/token, /v1/credentials/github/token |
| General | 20 | 1 per 1s | /oauth/authorize, /oauth/logout, /oauth/introspect, /oauth/revoke, /api/v1/org/*, /scim/v2/*, /v1/keys*, /api/v1/applications*, /api/webhooks/github, /admin/*, public SSH CA and KRL reads |
The bursts are sized for real client behavior: a full FAPI 2.0 login makes several rapid calls to
authentication endpoints, and kubectl spawns parallel credential processes at startup — hence the
larger credential burst.
Every response carries x-ratelimit-limit and x-ratelimit-remaining. A rejected request gets
429 with retry-after and x-ratelimit-after.
Not rate-limited at all: /health, /health/ready, /metrics, /, /static/*, /oauth/jwks,
/oauth/userinfo, /oauth/callback, /saml/acs, and the .well-known endpoints.
Rate limiting keys on client IP, so it depends on
VOUCH_TRUSTED_PROXIES. Behind an unconfigured proxy, every user shares one bucket and a moderately busy deployment will 429 everyone at once. This is the single most common cause of unexplained 429s.
Response headers
Applied to every response, with no configuration:
| Header | Value |
|---|---|
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() |
Cross-Origin-Opener-Policy | same-origin |
Cross-Origin-Resource-Policy | same-origin |
X-DNS-Prefetch-Control | off |
Cache-Control | no-cache (API routes additionally get no-store, must-revalidate) |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload — only when TLS is configured |
HSTS is emitted only when Vouch itself terminates TLS. If you terminate at a proxy, the proxy must add HSTS.
Content Security Policy
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self';
font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self';
form-action 'self' <IdP origins>
No unsafe-inline and no nonces — every script and stylesheet is served from the origin.
form-action is widened at startup with the origin of each configured identity provider. This is
required, not decorative: Chromium enforces form-action across redirects, so the POST /device
→ IdP redirect is blocked without it. Adding an IdP therefore changes the CSP, which takes effect
on restart.
CORS
- API routes allow any origin with credentials disabled. Safe because they authenticate with headers and bodies, never cookies.
- UI routes are same-origin by default.
VOUCH_CORS_ORIGINSopts in specific origins with credentials enabled. /oauth/authorizeand/oauth/logoutsend no CORS headers at all, under either setting. RFC 9700 §2.6: “CORS MUST NOT be supported at the authorization endpoint, as the client does not access this endpoint directly; instead, the client redirects the user agent to it.” Both are reached by top-level browser navigation, which does not consult CORS, so nothing that worked before stops working.
VOUCH_CORS_ORIGINS=* is a fatal startup error. UI routes use credentialed cookie sessions,
and the CORS specification forbids combining wildcard origins with credentials. List origins
explicitly.
Request limits
| Limit | Value |
|---|---|
| Global request timeout | 30 seconds (408 on expiry) |
| Global body limit | 256 KiB |
| Credential issuance | 8 KiB |
SCIM, /oauth/authorize, SAML ACS | 64 KiB |
| Enroll and login WebAuthn | 32 KiB |
| GitHub webhook | 1 MiB |
Server-side request forgery
Before fetching any URL a client controls — an OAuth client’s jwks_uri at dynamic registration,
or a JAR request_uri — Vouch resolves the hostname and rejects the request if any A or AAAA
record points somewhere non-global: loopback, RFC 1918, link-local (including
169.254.169.254), CGNAT, multicast, documentation and benchmarking ranges, and the IPv6
equivalents.
This matters because POST /oauth/register is unauthenticated, so the jwks_uri fetch happens
before any client has proven anything.
Loopback is permitted only when TLS is not configured, i.e. local development. Cloud metadata addresses stay blocked even then.
The upstream IdP discovery and SAML metadata fetches are deliberately exempt — those URLs come from your configuration, not from a client, and legitimately point at internal hosts.
Certification test mode
VOUCH_CERTIFICATION_TEST_TOKEN=<token>
Never set this in production. It exists for running the OpenID Foundation conformance suite, and it does three things:
- Registers
/certification/complete-loginand/certification/deny-login— a login bypass that mints a session for a synthetic user with no FIDO2 credential. - Disables all rate limiting, globally.
- Relaxes the requirement that at least one upstream IdP be configured.
The server logs a warning to the security target at startup when it is active. If you find that
warning in a production log, treat it as an incident: see the
Security Incident Runbook.
Hardening checklist
-
VOUCH_JWT_SECRETis at least 32 random characters, or KMS HMAC is used instead - Durable
VOUCH_OIDC_SIGNING_KEYandVOUCH_OIDC_RSA_SIGNING_KEY— not the ephemeral defaults - SSH CA key provisioned explicitly, so it cannot be silently auto-generated
-
VOUCH_ALLOWED_DOMAINSset, so enrollment is not open to any domain - Client IP preserved if anything fronts the server —
VOUCH_TRUSTED_PROXIESfor a proxy that terminates TLS, or client IP preservation on a TCP-passthrough target group -
VOUCH_CERTIFICATION_TEST_TOKENunset -
VOUCH_METRICS_BEARER_TOKENset to a strong random value if metrics are scraped -
VOUCH_ALLOWED_AAGUIDSset if you restrict enrollment to particular authenticator models (verified attestation chains are always required and need no configuration) - TLS terminated in Vouch where possible; HSTS present either way
- Database and S3 configuration encrypted at rest with least-privilege access
Sessions and Tokens
Vouch sessions are time-limited, DPoP-bound OAuth 2.0 access tokens (ES256 JWTs per RFC 9068) that prove recent hardware presence verification.
Session Lifecycle
- Creation —
vouch loginperforms FIDO2 assertion with YubiKey touch + PIN - Active — Access token stored in agent memory, valid for 8 hours (default)
- Usage — Credential helpers exchange the access token for service-specific credentials
- Expiry — Session expires automatically after the configured duration
- Revocation —
vouch logoutexplicitly ends the session
Session Duration
Default: 8 hours. Configurable via:
VOUCH_SESSION_HOURS=8
Where sessions live
Server-side, every session is a database record holding a hash of the token, never the token itself. The token exists in full only on the client.
On the client, the access token is held in the vouch-agent process and, as a fallback, in files
under the user’s XDG directories. That is client-side territory and is documented with the CLI at
vouch.sh/docs — it is not something you configure or operate on the
server.
Expiry and cleanup
Expired sessions are cleaned up automatically by a background task:
# Cleanup interval in minutes (default: 15, set to 0 to disable)
VOUCH_CLEANUP_INTERVAL=15
Security Properties
- Presence-bound — Every session traces to a FIDO2 assertion with user verification
- Time-limited — Sessions cannot be renewed; a new login is required after expiry
- DPoP-bound — Access tokens are bound to the client’s DPoP key; token theft without the key is useless
- Non-transferable — Sessions are bound to the client that created them
- Audience-restricted — Tokens narrowed to a specific resource are rejected at every other resource (see below)
- Audited — Every session creation and usage is logged
Audience Enforcement (RFC 8707 Resource Indicators)
Access tokens carry an aud (audience) claim per RFC 9068. By default the
audience equals the requesting client_id and the token is valid at every
Vouch resource endpoint — this covers all standard flows (vouch login,
browser sessions, device flow, client credentials).
A client may instead narrow a token to a specific resource, either with the
RFC 8707 resource parameter at the authorization endpoint or with the
audience/resource parameters at token exchange (RFC 8693). Vouch’s
resource endpoints (/v1/credentials/*, /v1/keys, /api/v1/*, RFC 7592
client management) enforce that narrowing: a narrowed token is accepted only
when its audience names this deployment (same scheme, host, and port as the
configured base URL) and its path covers the request at a path-segment
boundary. An audience of the deployment root (the base URL itself) covers
every endpoint; {base_url}/v1/keys covers /v1/keys and everything below
it, but nothing else. Requests failing the check receive 401 invalid_token
with the standard WWW-Authenticate challenge, and the rejection is logged
with the client ID, audience, and request path.
Per their RFCs, the authorization-server endpoints remain audience-agnostic:
/oauth/userinfo accepts tokens from any client, /oauth/introspect and
/oauth/revoke answer about any token the server issued, and token exchange
accepts narrowed subject tokens (re-scoping them is its purpose).
Clients registered without resource_uris may request any resource value
at issuance. This is safe under enforcement: a token narrowed to an external
resource server is less usable at Vouch, not more — it can only be spent at
the external service it names. Registering resource_uris additionally
restricts which values a client may request at all.
Running Multiple Instances
Vouch runs stateless: all shared state lives in the database, so scaling out is mostly a matter of running more processes behind a load balancer. The parts that are not automatic are the signing keys — get those wrong and the deployment fails in ways that look random.
Requirements
1. A shared database
SQLite is per-process and cannot back more than one instance. Use PostgreSQL, or Aurora DSQL on AWS. See Database.
2. Identical signing keys on every instance
This is the requirement that bites. Three keys must be the same value on every instance:
| Key | If it differs between instances |
|---|---|
VOUCH_OIDC_SIGNING_KEY (ES256) | Access tokens issued by instance A fail verification at instance B |
VOUCH_OIDC_RSA_SIGNING_KEY (RS256) | AWS credential tokens and RS256 ID tokens fail verification; AWS federation breaks |
VOUCH_JWT_SECRET | Authorization codes, WebAuthn challenge state, and CSRF tokens minted by one instance are rejected by another |
Both OIDC keys are optional settings that silently auto-generate an ephemeral key when unset. On a single node that only means tokens die at restart. Across several nodes it means each instance signs with its own key, and every request that lands on a different instance than the one that issued the token fails.
The symptom is the giveaway: intermittent failures at roughly (n-1)/n of requests, which look
like flakiness rather than a configuration error. Logins that work on retry. AWS federation that
succeeds sometimes.
The server warns at startup when it generates an ephemeral key:
Using ephemeral OIDC signing key -- all issued tokens will be invalidated on server
restart. Set VOUCH_OIDC_SIGNING_KEY to persist.
Using ephemeral OIDC RSA signing key -- AWS credential tokens (and RS256 ID tokens)
will fail verification after a restart and across multiple instances.
Treat either warning as a failed deployment in a multi-instance setup.
The cleanest way to guarantee consistency is to put the keys in the S3 configuration document or use KMS key IDs, so every instance reads the same source rather than relying on the environment being identical everywhere.
3. Consistent everything else
VOUCH_RP_ID and VOUCH_BASE_URL must match across instances — WebAuthn credentials are bound to
the RP ID. So must the SSH CA key, or certificates will be signed by CAs your hosts do not trust.
What you do not need
- Sticky sessions. Sessions live in the database and every instance can serve any request.
- Cross-instance coordination for cleanup. The background cleanup task staggers itself with a random jitter of up to 20% of the configured interval, so replicas do not all sweep at once.
- A migration step. Migrations run automatically at startup.
Startup and migrations
Every instance runs migrations at boot. Starting several at once is safe:
- On PostgreSQL, sqlx’s advisory lock serializes them.
- On Aurora DSQL, which has no advisory locks and cannot mix DDL and DML in one transaction,
Vouch uses a dedicated migration runner. It records completion with
ON CONFLICT DO NOTHING, so a replica that loses the race does not crash-loop, and it treats duplicate-object errors as evidence that a prior attempt already applied the DDL, so a crashed migration does not permanently block startup.
Rolling deployments are otherwise unremarkable: instances are interchangeable, and old and new versions can serve simultaneously as long as they share the keys above.
Load balancer configuration
Covered in Behind a Reverse Proxy. The two things to get right for a multi-instance deployment specifically:
- Health check
/health/ready, not/health. An instance that lost its database connection keeps passing/healthand stays in rotation. Behind a TCP-passthrough NLB this has to be an HTTPS health check on port 443, because port 80 serves only/health. - Preserve the client IP, or all rate limiting collapses onto the load balancer’s IP. With TCP
passthrough that means enabling client IP preservation on the target group; with a proxy that
terminates TLS it means setting
VOUCH_TRUSTED_PROXIES.
Graceful shutdown
On SIGTERM or Ctrl-C, the server stops accepting connections and gives in-flight requests up to
30 seconds to finish, then closes the database pool and flushes any pending OpenTelemetry
spans.
Set your orchestrator’s termination grace period above 30 seconds so it does not SIGKILL mid-
drain — Kubernetes defaults to 30, which leaves no margin.
The background cleanup and S3 polling tasks are aborted rather than drained; an interrupted cleanup pass resumes on the next instance’s next tick.
Regional and multi-region notes
Aurora DSQL deployments can map regions to endpoints with the dsql_endpoints object in the S3
configuration, resolved at startup from AWS_AZ or AWS_REGION. See the
S3 Configuration Schema.
DSQL connections authenticate with generated IAM tokens, refreshed automatically every 10 minutes against a 15-minute expiry. A refresh failure is logged as a warning and retried; it is not fatal.
Backup and Recovery
What to Back Up
| Component | Criticality | Recovery Impact |
|---|---|---|
| Document encryption KMS key | Unrecoverable | On an encrypted deployment, every stored document — including the entire audit history — becomes permanently unreadable. There is no regeneration path. |
| Database | Critical | Loss of user registrations, sessions, authenticator records |
| SSH CA private key | Critical | Must re-distribute new CA public key to all hosts |
| OIDC signing key (ES256) | High | Token verification fails until new key distributed |
| OIDC RSA signing key (RS256) | High | RS256 ID token verification fails until new key distributed |
| JWT secret | High | All sessions invalidated on change |
| TLS certificate & key | Medium | Service unavailable until replaced |
| Server configuration | Medium | Can be reconstructed from documentation |
The document encryption key is the one you cannot recover from. Every other item on this list can be regenerated at some cost: issue a new SSH CA and redistribute it, generate new signing keys and make users log in again. Documents sealed by a deleted KMS customer master key are gone, and that includes the audit history you would need to reconstruct anything.
It also fails in a way you will not notice: the server refuses to start, long after the key was scheduled for deletion. Enable KMS key deletion protection, and never delete a key that has ever sealed documents — even one you believe is unused.
Backup Strategy
Database
SQLite:
# Simple file copy (stop writes first or use backup API)
cp /data/vouch.db /backup/vouch.db.$(date +%Y%m%d_%H%M%S)
# Or use SQLite backup command (safe during writes)
sqlite3 /data/vouch.db ".backup '/backup/vouch.db.backup'"
PostgreSQL:
pg_dump -Fc vouch > /backup/vouch.$(date +%Y%m%d_%H%M%S).dump
Frequency: Daily minimum. More frequent for high-activity deployments.
Cryptographic Keys
Back up all keys to a secure, offline location:
# SSH CA key
cp ssh_ca_key /secure-backup/ssh_ca_key
# OIDC signing key (ES256)
cp oidc_signing_key.pem /secure-backup/oidc_signing_key.pem
# OIDC RSA signing key (RS256) — if configured
cp oidc_rsa_key.pem /secure-backup/oidc_rsa_key.pem
Store key backups:
- Encrypted at rest
- In a separate location from the server
- With restricted access (minimum two-person rule for production)
Document encryption key
If your S3 configuration contains a document_key block, that block and the KMS key it names are
part of your backup set:
- The KMS customer master key cannot be exported. Protect it instead: enable deletion protection, enable automatic key rotation only if you understand the implications for existing ciphertext, and replicate it as a multi-region key if you run in more than one region.
- The
document_keyblock in the S3 configuration holds the KMS-encrypted private key. Back it up with the rest of the configuration document; S3 versioning gives you this for free.
Both are required. The block without the KMS key is undecryptable, and the KMS key without the block has nothing to decrypt.
Recovery Procedures
Full Server Recovery
- Deploy new server with the same configuration
- Restore database from backup
- Restore cryptographic keys (SSH CA, OIDC signing, JWT secret)
- Start the server — migrations run automatically if needed
- Verify:
curl https://auth.example.com/health
Lost SSH CA Key
If the SSH CA key is lost and no backup exists:
- Generate a new SSH CA key
- Distribute the new public key to all SSH hosts
- Configure Vouch with the new key
- All users must run
vouch loginto get new certificates
Lost JWT Secret
If the JWT secret changes (lost or compromised):
- Set the new
VOUCH_JWT_SECRET - Restart the server
- All existing sessions are invalidated
- Users must run
vouch loginagain
Database Corruption
- Stop the server
- Restore from backup
- Users who enrolled after the backup must re-enroll
- Start the server
Disaster Recovery Testing
Test the recovery procedures before you need them:
- Restore a database backup to a test environment
- Start a test server with production keys
- Verify enrollment, login, and credential flows
- Document any issues and update procedures
Software Updates
Update Procedure
Pre-Update
- Read the release notes for breaking changes
- Back up the database before upgrading
- Test in staging before production
Server Update
# Back up database
cp /data/vouch.db /data/vouch.db.pre-upgrade
# Update via package manager
sudo apt upgrade vouch-server # Debian/Ubuntu
sudo dnf upgrade vouch-server # RHEL/Fedora
# Or via Docker
docker pull ghcr.io/vouch-sh/vouch:latest
docker compose up -d
# Or via Helm
helm upgrade vouch-server oci://ghcr.io/vouch-sh/charts/vouch-server \
--version <new-version> --namespace vouch
# Verify
curl -k https://auth.example.com/health
Database migrations run automatically on startup. No manual migration steps are needed.
Client Update
# macOS
brew upgrade vouch
# Linux
sudo apt upgrade vouch # Debian/Ubuntu
sudo dnf upgrade vouch # RHEL/Fedora
Rollback
To roll back:
- Stop the server
- Restore the database from the pre-upgrade backup
- Install the previous version
- Start the server
Database migrations have no rollback step — restoring the pre-upgrade backup is the rollback. Back up before upgrading.
Version Compatibility
- The server is backward-compatible with older CLI versions
- Upgrade the server first, then clients
- Release notes call out major versions that require simultaneous client updates
Release Channels
| Channel | Stability | Use Case |
|---|---|---|
latest | Stable releases | Production |
x.y.z | Pinned version | Production (recommended) |
main | Development builds | Testing only |
Troubleshooting
The server won’t start
Vouch fails fast: rather than starting in a half-configured state, it validates everything at boot and exits with a message naming the problem. Read the last line of output — it is almost always specific enough to act on.
The full list of fatal conditions is in Startup Validation. The ones that come up most:
No upstream IdP configured. Set VOUCH_IDPS=<slug>[,<slug>...]
At least one identity provider is mandatory. Set VOUCH_IDPS plus that slug’s
VOUCH_IDP_<SLUG>_* variables. Remember that hyphens in a slug become underscores in the variable
names: corp-saml → VOUCH_IDP_CORP_SAML_*.
Failed to configure IdP '<id>'
Discovery or metadata could not be fetched at startup. Check that the issuer or metadata URL is
reachable from the server and returns what it should:
curl -s "$VOUCH_IDP_GOOGLE_ISSUER/.well-known/openid-configuration" | jq .issuer
If the IdP uses an internal certificate authority, set VOUCH_EXTRA_CA_CERTS to a PEM bundle
containing it. If the discovery document’s issuer field differs from what you configured — even
by a trailing slash — they must be made to match.
VOUCH_JWT_SECRET must be at least 32 characters
Generate one with openssl rand -base64 48. A secret made of one repeated character is also
rejected, and one with fewer than 8 distinct bytes logs a warning.
Partial TLS configuration: set both VOUCH_TLS_CERT and VOUCH_TLS_KEY ... or neither.
Both or neither. Setting only one is always a mistake, so it is refused rather than silently
serving plaintext.
Duplicate IdP slug '<id>'
Two entries in VOUCH_IDPS (or idps[].id in S3) share a slug. Rename one.
Wildcard CORS rejected
VOUCH_CORS_ORIGINS=* is invalid. UI routes use credentialed cookie sessions, and the CORS
specification forbids wildcard origins with credentials. List origins explicitly.
issuer subdomains are claimed but document encryption is not configured
An organization claimed an issuer subdomain while a document encryption key was configured, and
that key is now absent. Per-organization signing keys are never stored in plaintext, so the server
will not start without the key that decrypts them. Restore the document_key block in your S3
configuration, or release the subdomains before starting.
Failed to fetch S3 configuration
S3 configuration is enabled and the object could not be fetched or parsed. Unlike runtime polling —
which fails open and keeps the running configuration — startup fails closed. Check the bucket name,
key, region, and that the instance role has s3:GetObject and s3:HeadObject.
Failed to start mTLS listener
The mTLS listener starts automatically whenever TLS is configured, and a bind failure on its port
is fatal. The most common cause is another process on the port; change it with VOUCH_MTLS_PORT.
The server starts but binds the wrong port.
Not an error. When TLS is configured, VOUCH_LISTEN_ADDR is ignored and the server binds 443 and
80. See Ports and Endpoints.
Port 80 fails to bind but the server keeps running.
Also expected: this is logged as a warning, not a fatal error, and you lose only the HTTP→HTTPS
redirect. On Linux, binding below 1024 needs CAP_NET_BIND_SERVICE.
Common Issues
Server Connection Issues
“Connection refused” or timeouts
- Check server health:
curl -k https://auth.example.com/health - Check DNS resolution:
dig auth.example.com - Check TLS:
openssl s_client -connect auth.example.com:443 - Check firewall rules (port 443 must be accessible)
SCIM Provisioning Issues
User not de-provisioned
- Verify the SCIM bearer token is valid and not expired
- Check the SCIM audit log for errors
- Confirm the IdP is sending DELETE requests to the correct endpoint
SCIM token rejected
- Tokens are shown once at creation and cannot be retrieved after
- Generate a new token via the admin API (
POST /api/v1/org/scim-tokens) and update the IdP configuration
Mutual-TLS Client Authentication Issues
subject mismatch for a client using tls_client_auth
The registered tls_client_auth_subject_dn must be the RFC 4514 string
representation of the certificate subject, which lists RDNs in the reverse of
their DER order. OpenSSL prints that order only when you ask for it:
openssl x509 -in client.crt -noout -subject -nameopt rfc2253
The default -subject output, the Subject: line in -text, and
-nameopt oneline all print the opposite RDN order, so pasting any of them
for a subject with two or more RDNs gives subject mismatch. For example, a
certificate issued with -subj '/O=Acme/CN=foo' prints O=Acme, CN=foo by
default but must be registered as CN=foo,O=Acme.
When the registered value matches only after reversing the RDN order, the
server logs a warning naming this as the cause — search the log for
RDN order reversed.
Spacing and attribute-name case are not significant: O = Acme, o=acme,
and O=Acme all compare equal, as does a multi-valued RDN written
CN=foo + O=Acme or CN=foo+O=Acme.
Identity Provider Issues
“Failed to fetch upstream OIDC discovery document”
- Verify the configured
VOUCH_IDP_<SLUG>_ISSUERis correct and reachable:curl -s $VOUCH_IDP_<SLUG>_ISSUER/.well-known/openid-configuration | jq .issuer - Verify the issuer URL uses HTTPS (HTTP is only allowed for
localhost) - Confirm the server can make outbound HTTPS requests (firewall, proxy)
“Issuer mismatch” during OIDC discovery
- The
issuerfield in the discovery document must exactly matchVOUCH_IDP_<SLUG>_ISSUER(trailing slashes matter) - Some providers require a trailing slash (e.g., Auth0:
https://tenant.auth0.com/) - Entra
/organizations/v2.0is special-cased — its{tenantid}template issuer is accepted - Entra
/common/v2.0is rejected at startup; use/organizations/v2.0or a single-tenant URL (see Microsoft Entra ID)
“Failed to fetch SAML IdP metadata”
- Verify the configured
VOUCH_IDP_<SLUG>_METADATA_URLis correct and reachable - Verify the URL returns XML, not an HTML login page
- Confirm the server can make outbound HTTPS requests
SAML signature verification errors
- Confirm the IdP’s signing certificate in the metadata is current and not expired
- Confirm the server clock is NTP-synchronized — SAML assertions have time-based validity windows (5 minutes of skew tolerance is common)
- Verify the IdP assertion signing algorithm matches what the server expects
“Duplicate IdP slug”
- Every entry in
VOUCH_IDPS/idps[].idmust be unique. Rename one of them.
Debug Logging
Enable verbose logging for troubleshooting:
# Server
RUST_LOG=debug vouch-server
For component-specific logging:
RUST_LOG=vouch_server=debug
Getting Help
- GitHub Issues — Bug reports
- GitHub Discussions — Questions
- Security Issues — Security vulnerabilities
Security Incident Runbook
Procedures for containing a security incident on a Vouch deployment you operate. Each section is self-contained: find the scenario, follow the steps.
Two properties of Vouch shape everything here. Credentials are short-lived, so many problems bound themselves within hours. And nothing Vouch issues can be re-issued without a hardware key present, so revoking access does not create a recovery problem for legitimate users — they just log in again.
Triage: what was actually exposed?
| Compromised | Blast radius | Section |
|---|---|---|
| A user’s laptop or session | That user’s credentials only. Access tokens are DPoP-bound, so a stolen token without the client key is unusable. | One user |
| A user’s YubiKey (lost or stolen) | Nothing without their PIN — the key locks after 8 failed attempts. | One user |
| The SSH CA private key | An attacker can mint SSH certificates for any principal. | SSH CA key |
| An OIDC signing key | An attacker can mint access tokens and AWS federation assertions. | Signing keys |
| The JWT secret | An attacker can forge authorization codes and CSRF state. | JWT secret |
| A SCIM token | An attacker can create and delete users in your organization. | SCIM token |
| The database | Read access to audit history and token hashes. No usable private keys — they are not stored there. | Database |
| The document encryption KMS key | On an encrypted deployment, everything sealed by it. | Document key |
One user is compromised
Fastest containment, from the admin UI at /admin:
- Deactivate the member. This immediately deletes all their sessions, revokes all their SSH certificates, and clears their stored GitHub refresh token. Their enrolled authenticators survive, so it is reversible.
- If their hardware key itself is unaccounted for, use Revoke credentials instead — it does everything Deactivate does and deletes their enrolled authenticators, so the missing key cannot be used even by someone who learns the PIN.
- Review
/admin/auditfiltered to that user for what was issued before containment: look forssh_credential,aws_credential,github_credential, andtoken_exchange. - Revoke downstream credentials that outlive Vouch’s — AWS STS sessions in particular do not expire when the Vouch session does. Revoke them in the IAM console.
- When the user is ready to return, Activate them (after a Deactivate) or have them enroll a new key (after Revoke credentials).
See Organizations and Administrators for exactly what each action does.
The SSH CA key is compromised
An attacker holding this key can sign certificates for any principal on every host that trusts your CA. Treat as critical.
-
Generate a new CA key on a trusted machine:
ssh-keygen -t ed25519 -f ssh_ca_key.new -N "" -C "vouch-ca@example.com" -
Distribute the new public key to every host, alongside the old one initially:
cat ssh_ca_key.new.pub >> /etc/ssh/vouch-ca.pub -
Switch the server to the new key (
VOUCH_SSH_CA_KEY,VOUCH_SSH_CA_KEY_PATH, orVOUCH_SSH_CA_KMS_KEY_ID) and restart. -
Remove the old public key from every host. Do this immediately in a compromise — the usual advice to wait for outstanding certificates to expire assumes the old CA is trustworthy, and here it is not. Users re-run
vouch loginto get certificates from the new CA. -
Audit for abuse. Certificates minted by an attacker with the stolen key never touched your server, so they are not in
/admin/audit. Compare hostsshdlogs against thessh_credentialevents Vouch recorded; a successful certificate login with no corresponding issuance event is a forged certificate.
If the key was in KMS rather than on disk, the private material never left KMS — disable the key
and check CloudTrail for unexpected kms:Sign calls instead of assuming compromise.
Revoking individual certificates
Vouch publishes a revocation list, unauthenticated so hosts can poll it:
curl https://auth.example.com/v1/credentials/ssh/krl
# {"revoked_serials":[...],"total":N,"generated_at":"..."}
curl https://auth.example.com/v1/credentials/ssh/krl/<serial>
Certificates are revoked as a side effect of the member actions above, not through a standalone endpoint.
A signing key is compromised
Applies to VOUCH_OIDC_SIGNING_KEY (ES256, access and ID tokens) and
VOUCH_OIDC_RSA_SIGNING_KEY (RS256, AWS credential tokens).
- Generate a replacement — see Signing Keys.
- Update the configuration on every instance and restart. Mismatched keys across instances cause intermittent verification failures; see Running Multiple Instances.
- The JWKS endpoint (
/oauth/jwks) serves the new public key immediately, but relying parties cache it. AWS in particular caches JWKS for an undocumented period exceeding the advertised 1-hourCache-Control, so federation may fail until it refetches. - All previously issued tokens become invalid. Users run
vouch loginagain. - If the RS256 key was exposed, review CloudTrail for
AssumeRoleWithWebIdentitycalls you cannot attribute to anaws_credentialaudit event.
The JWT secret is compromised
VOUCH_JWT_SECRET signs authorization codes, WebAuthn challenge state, and CSRF tokens.
- Generate a new secret:
openssl rand -base64 48 - Update every instance and restart.
- Every session is invalidated and all users must run
vouch loginagain. There is no graceful rotation.
Consider moving to VOUCH_JWT_HMAC_KMS_KEY_ID afterwards, so the secret never exists as an
environment variable again.
A SCIM token is compromised
A SCIM token can create and delete users in your organization.
- Revoke it at
/admin/scim-tokens. Revocation takes effect immediately — tokens are checked against the database on every request. - Issue a replacement and update your IdP’s SCIM configuration.
- Review
/admin/auditforscim_operationevents, particularly user deletions you did not expect.
Tokens are stored as SHA-256 hashes, so a database leak does not itself expose usable tokens.
The database is exposed
The database holds audit history, user records, session records, and hashed tokens. It does not hold usable private key material: the SSH CA and OIDC signing keys come from the environment, S3 configuration, or KMS, and on an encrypted deployment the documents themselves are sealed with a KMS-held key.
- Rotate the JWT secret — session records are keyed against it.
- Rotate all SCIM tokens.
- Rotate OAuth client secrets for every registered application.
- Treat audit history as disclosed: it contains domain-masked emails, IP addresses, and geographic metadata.
- Enrolled WebAuthn credentials are public keys. They are not secret and need no rotation.
The document encryption key is compromised
On a deployment using S3 configuration with a document_key, a single KMS customer master key
seals every stored document. Compromise of that key is compromise of everything it sealed.
- Disable the KMS key and review CloudTrail for
kms:Decryptcalls you cannot account for. - Rotate every secret the documents contained: OAuth client secrets, SCIM tokens.
- There is no in-place document-key rotation mechanism. Provisioning a replacement requires
vouch-server generate-document-keyand a coordinated re-encryption.
Do not delete the old KMS key. Documents sealed with it become permanently unreadable, and that includes your audit history. See Backup and Recovery.
Certification test mode found enabled in production
If a production server logs the certification test-mode warning at startup, or
/certification/complete-login responds:
- Treat it as an active authentication bypass. It mints sessions for a synthetic user with no hardware key, and it disables all rate limiting.
- Unset
VOUCH_CERTIFICATION_TEST_TOKENand restart immediately. - Rotate the JWT secret to invalidate any session minted through the bypass.
- Review
/admin/auditforlogin_successevents with noauthenticator_id.
See Security Hardening.
Gathering evidence
# SQLite
sqlite3 /data/vouch.db \
"SELECT * FROM audit_events WHERE created_at > datetime('now','-7 days') ORDER BY created_at;"
# PostgreSQL
psql "$VOUCH_DATABASE_URL" -c \
"SELECT * FROM audit_events WHERE created_at > now() - interval '7 days' ORDER BY created_at;"
Administrative and organization-lifecycle events are never purged by retention, so the record of
who granted whom access survives regardless of your retention settings. Authentication and
credential events follow VOUCH_AUTH_EVENTS_RETENTION_DAYS and
VOUCH_OAUTH_EVENTS_RETENTION_DAYS — if you need a long forensic window, raise them before you
need it.
Correlate application logs by x-fapi-interaction-id; see
Monitoring and Metrics.
Reporting a vulnerability in Vouch itself
This runbook covers incidents in your deployment. To report a security vulnerability in the Vouch software, see the security policy at vouch.sh.
Air-Gapped Deployment
This chapter covers deploying Vouch in environments with no internet connectivity, such as defense contractors, government agencies, financial services, and critical infrastructure.
Supported with operational constraints — You can run
vouch-serverand thevouchCLI on an isolated network today using the same binaries and deployment paths as on-premise (systemd, Docker, or Kubernetes). This chapter documents that workflow. A few operator conveniences (listed under Roadmap below) are not built into the product yet.
Supported today
| Capability | Where to read |
|---|---|
| Server install (RPM/DEB, containers, Helm) | Installation, Deployment Methods |
| Configuration, TLS, database, SSH CA | Configuration Reference |
| Internal OIDC or SAML IdP | Identity Provider Overview, SAML 2.0 |
| Enrollment (YubiKey + browser on internal network) | Installation, YubiKey Provisioning |
| Key ceremony on a trusted workstation | Key Ceremony |
| Day-two ops (time sync, updates, audit export scripts) | Operations |
| Packages via sneakernet | packages.vouch.sh (download on a connected machine, transfer in) |
Enrollment uses the standard vouch enroll device flow (browser opens the verification URL on your internal Vouch host) or browser-only /enroll/start on the server UI. There is no separate air-gap-only CLI mode.
For general on-prem deployment (reachable IdP, standard updates), start with Deployment Overview.
Roadmap
These items are not available in the product today; the chapters above describe manual procedures instead:
- Server syslog / SIEM streaming — use periodic database export in Operations until built-in export exists
- Headless enrollment — enrollment without any browser on the internal network
Overview
In an air-gapped environment:
- No SaaS services available
- Updates delivered via sneakernet
- Internal identity provider (no Google Workspace)
- Time sync from isolated NTP or GPS
Vouch fits these constraints: the SSH CA is built in, and all state lives in a local database, so nothing depends on an external service.
Architecture
+--------------------------------------------------------------------------+
| AIR-GAPPED ENCLAVE |
| |
| +--------------------------------------------------------------------+ |
| | On-Premises Vouch Stack | |
| | | |
| | +--------------+ +----------------+ +-----------------------+ | |
| | | Vouch | | Built-in | | SQLite | | |
| | | Server | | SSH CA | | | | |
| | | | | | | * Users & credentials | | |
| | | * WebAuthn | | * Ed25519 CA | | * Sessions | | |
| | | * OIDC | | * SSH certs | | * Audit logs | | |
| | | * Sessions | | * 8hr TTL | | | | |
| | +--------------+ +----------------+ +-----------------------+ | |
| | | | | | |
| | +------------------+----------------------+ | |
| | | | |
| +----------------------------+---------------------------------------+ |
| | |
| | Internal Network Only |
| v |
| +--------------------------------------------------------------------+ |
| | Workstations | |
| | | |
| | +--------------+ +--------------+ +-------------------------+ | |
| | | Workstation | | Workstation | | Protected Resources | | |
| | | | | | | | | |
| | | * vouch CLI | | * vouch CLI | | * SSH servers | | |
| | | * YubiKey | | * YubiKey | | * Internal apps | | |
| | | * Certs | | * Certs | | * Databases | | |
| | +--------------+ +--------------+ +-------------------------+ | |
| | | |
| +--------------------------------------------------------------------+ |
| |
| +--------------------------------------------------------------------+ |
| | Time Infrastructure | |
| | +------------+ +-----------------+ | |
| | | GPS Time |---->| Internal NTP |----> All hosts | |
| | | Receiver | | (stratum 1) | | |
| | +------------+ +-----------------+ | |
| +--------------------------------------------------------------------+ |
+--------------------------------------------------------------------------+
|
| Air Gap (sneakernet)
v
+--------------------------------------------------------------------------+
| CONNECTED ENVIRONMENT |
| |
| * Signed software packages (from packages.vouch.sh) |
| * CA certificate updates |
| * (Optional) Audit log export |
+--------------------------------------------------------------------------+
Identity Provider Considerations
In an air-gapped environment, you cannot use external identity providers like Google Workspace for enrollment. Vouch Server requires at least one upstream IdP to verify user identity, so an air-gapped deployment must include a self-hosted IdP inside the enclave:
- Self-hosted OIDC provider — Deploy an internal OIDC-compliant IdP inside the enclave (e.g., Keycloak, Dex, or Microsoft AD FS). Add it to Vouch Server’s
VOUCH_IDPSlist withVOUCH_IDP_<SLUG>_TYPE=oidcplus the_ISSUER,_CLIENT_ID, and_CLIENT_SECRETvariables pointing to the internal IdP. - Self-hosted SAML provider — Deploy an internal SAML IdP (e.g., Shibboleth, AD FS) and configure it with
VOUCH_IDP_<SLUG>_TYPE=samlplusVOUCH_IDP_<SLUG>_METADATA_URLpointing to the internal metadata document.
Prerequisites
Hardware
- Servers for Vouch stack (VMs or bare metal)
- YubiKey 5 series for each user (firmware 5.2+)
- GPS receiver for time sync (recommended)
- USB drives for sneakernet transfers
Software (Pre-downloaded)
- Vouch Server packages (RPM/DEB from packages.vouch.sh)
- vouch CLI packages (RPM/DEB from packages.vouch.sh)
- Container images and/or Helm charts (for Kubernetes deployments)
Installation
This chapter walks through the complete installation procedure for deploying Vouch in an air-gapped environment, from downloading packages on a connected machine through enrolling users on the isolated network.
Step 1: Download Packages for Offline Transfer
On a connected machine, download the required packages from packages.vouch.sh:
# Import Vouch GPG signing key
curl -fsSL https://packages.vouch.sh/gpg/vouch.asc | gpg --import
# Download server RPM
curl -LO https://packages.vouch.sh/rpm/x86_64/vouch-server-<version>-1.x86_64.rpm
# Download CLI RPM (for each workstation architecture)
curl -LO https://packages.vouch.sh/rpm/x86_64/vouch-<version>-1.x86_64.rpm
curl -LO https://packages.vouch.sh/rpm/aarch64/vouch-<version>-1.aarch64.rpm
# For Debian/Ubuntu workstations
curl -LO https://packages.vouch.sh/apt/vouch_<version>_amd64.deb
curl -LO https://packages.vouch.sh/apt/vouch_<version>_arm64.deb
For container-based or Kubernetes deployments, also download:
# Pull and save container image
docker pull ghcr.io/vouch-sh/vouch:<version>
docker save ghcr.io/vouch-sh/vouch:<version> -o vouch-server-<version>.tar
# Download Helm chart (for Kubernetes)
helm pull oci://ghcr.io/vouch-sh/charts/vouch-server --version 0.1.0
Generate checksums for verification after transfer:
sha256sum vouch-server-*.rpm vouch-*.rpm vouch-*.deb vouch-server-*.tar > SHA256SUMS
gpg --detach-sign SHA256SUMS
Transfer all files to the air-gapped environment via approved media.
Step 2: Verify Package Integrity
On the air-gapped network:
# Import Vouch GPG signing key (transferred separately, verified out-of-band)
gpg --import vouch-release-key.pub
# Verify checksums
gpg --verify SHA256SUMS.sig SHA256SUMS
sha256sum -c SHA256SUMS
# Verify RPM signatures
rpm -K vouch-server-<version>-1.x86_64.rpm
rpm -K vouch-<version>-1.x86_64.rpm
Step 3: Install Packages
RPM-based installation (recommended for bare metal/VM):
# Install server
rpm -ivh vouch-server-<version>-1.x86_64.rpm
# Install CLI on workstations
rpm -ivh vouch-<version>-1.x86_64.rpm
DEB-based installation:
# Install CLI on Debian/Ubuntu workstations
dpkg -i vouch_<version>_amd64.deb
Container-based installation:
# Load container image into local Docker registry
docker load < vouch-server-<version>.tar
# Verify image loaded
docker images | grep vouch
Step 4: Secure Key Generation
Generate every key on a trusted, air-gapped workstation, following your organization’s key ceremony procedures. See the Key Ceremony chapter for per-key instructions.
Key Overview
| Key | Type | Format | Required | Purpose |
|---|---|---|---|---|
| JWT Secret | Symmetric | UTF-8 (32+ chars) | Yes | Signs internal state tokens (authorization codes, WebAuthn state, CSRF) |
| SSH CA Key | Ed25519 | Base64-encoded OpenSSH PEM | Optional | Sign SSH certificates |
| OIDC Signing Key | P-256 ECDSA | Base64-encoded PKCS#8 PEM | Optional* | Sign OIDC ID tokens |
| TLS Certificate | RSA/EC | Base64-encoded PEM | Optional | HTTPS encryption |
| TLS Private Key | RSA/EC | Base64-encoded PEM | Optional | HTTPS encryption |
*When unset, the server generates an ephemeral key at startup; issued tokens then fail verification after a restart.
Base64-encode all PEM keys and certificates passed via environment variables — multi-line PEM content does not survive as an environment-variable value.
Step 5: Database Setup
Vouch defaults to SQLite, which backs a single node. The database is created automatically on first startup.
# SQLite (default, single-node)
export VOUCH_DATABASE_URL="sqlite:/data/vouch.db?mode=rwc"
# Create the data directory, readable only by the service user
mkdir -p /data
chmod 700 /data
For multi-node deployments, use PostgreSQL on the internal network:
# PostgreSQL (multi-node, must be reachable on the internal network)
export VOUCH_DATABASE_URL="postgres://user:password@db.internal:5432/vouch"
Database migrations run automatically on server startup.
Step 6: Configure Vouch Server
Vouch is configured entirely through environment variables. Create a secure environment file:
# Create environment file (chmod 600 after editing)
cat > /etc/vouch/vouch.env << 'EOF'
# =============================================================================
# Vouch Server Configuration - Air-Gapped Environment
# =============================================================================
# -----------------------------------------------------------------------------
# Required Configuration
# -----------------------------------------------------------------------------
# JWT signing secret (minimum 32 characters)
VOUCH_JWT_SECRET=<your-64-character-secret-here>
# Relying Party configuration
VOUCH_RP_ID=auth.internal
VOUCH_RP_NAME=Vouch (Air-Gapped)
# Database
VOUCH_DATABASE_URL=sqlite:/data/vouch.db?mode=rwc
# -----------------------------------------------------------------------------
# Network Configuration
#
# Development (no TLS):
# Server listens on VOUCH_LISTEN_ADDR (default: 0.0.0.0:3000)
#
# Production (TLS enabled):
# Server automatically listens on port 443 (HTTPS) and port 80 (HTTP redirect)
# VOUCH_LISTEN_ADDR is ignored when TLS is configured
# HTTP requests on port 80 are 308 redirected to HTTPS on port 443
# The /health endpoint is accessible on HTTP (for load balancer health checks)
# Host header is validated against rp_id to prevent injection attacks
# Requires CAP_NET_BIND_SERVICE capability (handled by packaging scripts)
# -----------------------------------------------------------------------------
# Listen address (used only when TLS is NOT configured)
VOUCH_LISTEN_ADDR=0.0.0.0:3000
# Base URL (how clients reach the server)
VOUCH_BASE_URL=https://auth.internal
# -----------------------------------------------------------------------------
# TLS Configuration (base64-encoded PEM)
# Generate with: base64 -i cert.pem | tr -d '\n'
# -----------------------------------------------------------------------------
VOUCH_TLS_CERT=<base64-encoded-certificate>
VOUCH_TLS_KEY=<base64-encoded-private-key>
# -----------------------------------------------------------------------------
# SSH CA Configuration (base64-encoded PEM)
# Generate with: base64 -i ssh_ca_key | tr -d '\n'
# -----------------------------------------------------------------------------
# SSH CA private key (base64-encoded PEM, takes precedence over path)
VOUCH_SSH_CA_KEY=<base64-encoded-ssh-ca-private-key>
# Or use a file path instead (file contains raw PEM, not base64):
# VOUCH_SSH_CA_KEY_PATH=/secrets/ssh_ca_key
# -----------------------------------------------------------------------------
# OIDC Provider Configuration
# Generate with: base64 -i oidc_signing_key.pem | tr -d '\n'
# -----------------------------------------------------------------------------
# Vouch acts as an OIDC provider - this key signs the ID tokens (base64-encoded PEM)
VOUCH_OIDC_SIGNING_KEY=<base64-encoded-oidc-signing-key>
# -----------------------------------------------------------------------------
# Upstream Identity Provider (Required)
# At least one IdP must be configured; the server refuses to start without one.
# -----------------------------------------------------------------------------
VOUCH_IDPS=internal-oidc
VOUCH_IDP_INTERNAL_OIDC_TYPE=oidc
VOUCH_IDP_INTERNAL_OIDC_ISSUER=https://idp.internal
VOUCH_IDP_INTERNAL_OIDC_CLIENT_ID=vouch-client
VOUCH_IDP_INTERNAL_OIDC_CLIENT_SECRET=<client-secret>
# -----------------------------------------------------------------------------
# Session Configuration
# -----------------------------------------------------------------------------
# Session duration (default: 8 hours)
VOUCH_SESSION_HOURS=8
# Device code settings (for CLI enrollment)
VOUCH_DEVICE_CODE_EXPIRES=600
VOUCH_DEVICE_POLL_INTERVAL=5
# -----------------------------------------------------------------------------
# Security Configuration
# -----------------------------------------------------------------------------
# Allowed email domains for enrollment (comma-separated)
VOUCH_ALLOWED_DOMAINS=internal,company.local
# DPoP (Demonstrating Proof of Possession). DPoP is always enabled and cannot
# be turned off; only the accepted proof age is configurable.
VOUCH_DPOP_MAX_AGE=300
# Extra CA bundle for outbound HTTPS, so the server trusts an internal PKI when
# fetching the IdP's discovery document or SAML metadata.
VOUCH_EXTRA_CA_CERTS=/etc/vouch/internal-ca.pem
# -----------------------------------------------------------------------------
# Audit and Retention
# -----------------------------------------------------------------------------
# Cleanup interval (minutes, 0 to disable)
VOUCH_CLEANUP_INTERVAL=15
# Event retention (days)
VOUCH_AUTH_EVENTS_RETENTION_DAYS=730
VOUCH_OAUTH_EVENTS_RETENTION_DAYS=90
# -----------------------------------------------------------------------------
# Branding (Optional)
# -----------------------------------------------------------------------------
VOUCH_ORG_NAME=Your Organization
EOF
# Secure the environment file
chmod 600 /etc/vouch/vouch.env
Environment Variables Reference
See Environment Variables for the complete list with defaults, required-ness, and validation rules. Two settings matter more in an air-gapped enclave than anywhere else:
VOUCH_EXTRA_CA_CERTS— a PEM bundle of extra certificate authorities for the server’s outbound HTTPS client. An internal IdP almost always presents a certificate from a private CA, and without this the discovery or metadata fetch fails at startup and the server refuses to boot.VOUCH_SSH_CA_KEY_PATH— if the file at this path does not exist, the server generates a new Ed25519 CA key and writes it there. On a fresh volume that silently rotates your SSH CA and every host’sTrustedUserCAKeysentry stops matching. Provision the key before first start, or setVOUCH_SSH_CA_KEYwith the PEM contents.
Step 7: Deploy Services
Deploy the server with systemd, Docker Compose, or Helm.
Option A: Systemd Service (RPM Install)
If you installed via RPM, the vouch-server systemd service is configured automatically:
# Configure environment
cp /etc/vouch/vouch.env /etc/vouch/vouch.env.local
# Edit /etc/vouch/vouch.env.local with your settings
# Start and enable the service
systemctl enable --now vouch-server
# Check status
systemctl status vouch-server
# View logs
journalctl -u vouch-server -f
Option B: Docker Compose
# docker-compose.yml
services:
vouch-server:
image: ghcr.io/vouch-sh/vouch:<version>
container_name: vouch-server
restart: unless-stopped
ports:
- "443:443"
volumes:
- vouch-data:/data
- /etc/vouch/secrets:/secrets:ro
env_file:
- /etc/vouch/vouch.env
environment:
# Override or add environment variables here
VOUCH_DATABASE_URL: sqlite:/data/vouch.db?mode=rwc
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "--no-check-certificate", "https://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
volumes:
vouch-data:
# Start services
docker-compose up -d
# Verify container is running
docker-compose ps
# Check logs for startup errors
docker-compose logs -f vouch-server
Option C: Helm Chart (Kubernetes)
A Helm chart is available for Kubernetes deployments. After transferring the chart archive to the air-gapped environment:
# Install from the downloaded chart archive
helm install vouch-server vouch-server-0.1.0.tgz \
--namespace vouch \
--create-namespace \
--set image.repository=vouch-server \
--set image.tag=<version> \
--values my-values.yaml
See the chart’s values.yaml for all configurable options including secrets, ingress, and persistent storage.
Verify Deployment
Regardless of deployment method:
# Verify health endpoint
curl -k https://auth.internal/health
# Expected: ok
# Verify SSH CA is loaded (if configured)
curl -k https://auth.internal/v1/credentials/ssh/ca
# Expected: {"public_key":"ssh-ed25519 AAAA...","comment":"vouch-ca@auth.internal"}
Step 8: Distribute CA Public Key
The SSH CA public key must be trusted by all SSH servers in the air-gapped environment:
The endpoint returns JSON, so extract the public_key field — writing the raw response into the
file puts JSON where sshd expects a key, and every certificate login fails:
# Fetch CA public key via API
curl -sk https://auth.internal/v1/credentials/ssh/ca | jq -r .public_key > vouch-ca.pub
# Confirm it looks like a key, not JSON
cat vouch-ca.pub
# ssh-ed25519 AAAA...
# Copy to all SSH servers
scp vouch-ca.pub root@server:/etc/ssh/vouch-ca.pub
# Configure SSH server to trust the CA
echo "TrustedUserCAKeys /etc/ssh/vouch-ca.pub" >> /etc/ssh/sshd_config
# Optionally, configure authorized principals
echo "AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u" >> /etc/ssh/sshd_config
# Restart SSH daemon
systemctl restart sshd
AuthorizedPrincipals Setup (Optional but Recommended):
# Create principals directory
mkdir -p /etc/ssh/auth_principals
# For each user, create a file with allowed principals
# Vouch issues certificates with two principals: email and username
echo "john@company.internal" > /etc/ssh/auth_principals/john
echo "john" >> /etc/ssh/auth_principals/john
Step 9: Point Workstations at the Internal Server
Each workstation needs the CLI pointed at the enclave’s Vouch server, either per-invocation with
--server or via the environment:
export VOUCH_SERVER=https://auth.internal
The CLI has no option for supplying an extra CA bundle. If your Vouch server presents a
certificate from an internal CA, that CA must be installed in the workstation’s operating
system trust store — the same requirement browsers have for the enrollment flow. Distribute it
through the same channel you already use for internal PKI (MDM profile, update-ca-trust,
update-ca-certificates).
This is the workstation side. For CLI configuration in general — installation,
vouch enroll, and the credential helpers — see the CLI documentation at vouch.sh/docs.
Step 10: Enroll Users
Important: Enrollment requires browser access to the Vouch server’s web UI on the internal network. Users run
vouch enrollon a workstation that can open the device verification URL against the internal Vouch host; there is no separate headless-only enroll mode.
Each user:
- Opens a browser to
https://auth.internal/enroll - Authenticates via the configured identity provider
- Registers their YubiKey through the browser’s WebAuthn prompt (touch + PIN)
After enrollment, daily login uses the CLI (vouch login) with no browser required.
Key Ceremony
This chapter covers generating the keys an air-gapped Vouch deployment needs. Generate every key on a trusted, air-gapped workstation, following your organization’s key ceremony procedures.
JWT Secret Generation (Required)
The JWT secret signs internal state tokens — authorization codes, WebAuthn challenge state, and CSRF tokens. It must be at least 32 characters.
# Generate cryptographically secure 64-character secret
openssl rand -base64 48
# Alternative using /dev/urandom
head -c 48 /dev/urandom | base64
# Store securely - this will be VOUCH_JWT_SECRET
Security Notes:
- Use a minimum of 32 characters (48+ recommended)
- Never reuse secrets across environments
- Rotate periodically (requires re-authentication of all users)
SSH CA Key Generation (Ed25519)
The SSH CA signs user SSH certificates. Provision the key deliberately: if none is provided, the server generates one at ./ssh_ca_key on first start (see the auto-generation warning in Key Management). Set VOUCH_SSH_CA_KEY_PATH="" to disable the SSH CA.
# Generate Ed25519 SSH CA key pair (no passphrase for automated use)
ssh-keygen -t ed25519 -f ssh_ca_key -N "" -C "vouch-ca@auth.internal"
# Set restrictive permissions
chmod 600 ssh_ca_key
# Verify key type and fingerprint
ssh-keygen -l -f ssh_ca_key
# Output: 256 SHA256:xxxx vouch-ca@auth.internal (ED25519)
# View public key (for distribution to SSH servers)
cat ssh_ca_key.pub
Environment variable format (base64-encoded):
# Option 1: Provide base64-encoded key content (preferred for containers)
export VOUCH_SSH_CA_KEY="$(base64 -i ssh_ca_key | tr -d '\n')"
# Option 2: Provide path to key file (file contains raw PEM, not base64)
export VOUCH_SSH_CA_KEY_PATH="/secrets/ssh_ca_key"
Key Storage Options:
- HSM (recommended for high-security) – Store in hardware security module
- Encrypted file with split knowledge – Two administrators hold partial keys
- YubiKey PIV (for smaller deployments) – Store on hardware token
OIDC Signing Key Generation (P-256 ECDSA)
The OIDC signing key signs ID tokens using the ES256 algorithm. If unset, the server generates an ephemeral key at each start, and every previously issued token fails verification.
# Generate P-256 EC private key in PKCS#8 format
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out oidc_signing_key.pem
# Set restrictive permissions
chmod 600 oidc_signing_key.pem
# Verify key type
openssl ec -in oidc_signing_key.pem -text -noout 2>/dev/null | head -3
# Output should include: Private-Key: (256 bit, prime256v1)
# Extract public key (for debugging/verification)
openssl ec -in oidc_signing_key.pem -pubout -out oidc_signing_key.pub
Environment variable format (base64-encoded):
# Provide base64-encoded PEM content
export VOUCH_OIDC_SIGNING_KEY="$(base64 -i oidc_signing_key.pem | tr -d '\n')"
OIDC RSA Signing Key Generation (RSA-3072)
The OIDC RSA signing key signs ID tokens with RS256 algorithm per OIDC Core Section 3.1.3.7 and all AWS credential tokens (/v1/credentials/aws/token, serving both STS AssumeRoleWithWebIdentity and IAM Identity Center CreateTokenWithIAM). Any deployment using the AWS integration must provide a durable key — without one, an ephemeral RSA-3072 key is generated on each server restart, and AWS token verification breaks after restarts and across multiple instances.
# Generate RSA-3072 private key in PKCS#8 format
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out oidc_rsa_key.pem
# Set restrictive permissions
chmod 600 oidc_rsa_key.pem
# Verify key type and size
openssl rsa -in oidc_rsa_key.pem -text -noout 2>/dev/null | head -3
# Output should include: Private-Key: (3072 bit)
Environment variable format (base64-encoded):
# Provide base64-encoded PEM content
export VOUCH_OIDC_RSA_SIGNING_KEY="$(base64 -i oidc_rsa_key.pem | tr -d '\n')"
TLS Certificate Generation
For production, use certificates signed by your internal CA. For testing, use a self-signed certificate.
# Generate EC private key and self-signed certificate
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout tls_key.pem -out tls_cert.pem -days 365 -nodes \
-subj "/CN=auth.internal" \
-addext "subjectAltName=DNS:auth.internal,DNS:localhost"
# Set restrictive permissions
chmod 600 tls_key.pem
# Verify certificate
openssl x509 -in tls_cert.pem -text -noout | head -15
Environment variable format (base64-encoded):
# Base64 encode for environment variables
export VOUCH_TLS_CERT="$(base64 -i tls_cert.pem | tr -d '\n')"
export VOUCH_TLS_KEY="$(base64 -i tls_key.pem | tr -d '\n')"
Key Security Best Practices
- File Permissions: Always use
chmod 600for private keys - Never Commit Keys: Add
*.pem,*_key,*.keyto.gitignore - Audit Key Access: Log all access to key material
- Backup Securely: Store encrypted backups in separate secure location
- Document Fingerprints: Record key fingerprints in secure documentation
- Key Rotation: Plan for periodic rotation (SSH CA annually, JWT secret quarterly)
YubiKey Provisioning
In an air-gapped environment, YubiKey provisioning happens entirely on the internal network through the Vouch server’s web UI. This chapter covers the provisioning workflow, hardware requirements, and spare key strategy.
Provisioning Workflow
- User opens
https://auth.internal/enroll/startin a browser on their workstation - User authenticates with the internal identity provider
- User inserts their YubiKey and completes the WebAuthn registration flow
- User sets a PIN on their YubiKey if one is not already configured (minimum 8 characters)
- The credential is registered and the user can begin authenticating
There is no admin pre-creation step: enrollment is user-initiated, and the first enrollee from a domain becomes that organization’s administrator — see Organizations and Administrators.
YubiKey Requirements
- YubiKey 5 series with firmware 5.2+
- FIDO2/WebAuthn support enabled
- PIN configured (minimum 8 characters)
Spare Key Strategy
Register at least two YubiKeys per user (primary and backup). If a YubiKey is lost or damaged:
- User reports lost key to administrator
- Administrator revokes the lost key’s credential via the web UI
- User registers their backup YubiKey
Operations
This chapter covers the day-to-day operational procedures for maintaining a Vouch deployment in an air-gapped environment, including time synchronization, software updates, audit log export, disaster recovery, and troubleshooting.
Time Synchronization
Certificate validity depends on accurate time. Options for air-gapped networks:
GPS Time Receiver (Recommended)
+----------------+ +--------------------+
| GPS Receiver |---->| Internal NTP |
| (one-way data) | | Server (stratum 1) |
+----------------+ +--------------------+
| |
| v
One-way only All internal hosts
(no data out)
Configure NTP clients:
# /etc/ntp.conf
server ntp.internal iburst
Manual Time Sync
For truly isolated networks without GPS:
- Reference time from secure source (atomic clock, verified external)
- Set time on NTP server manually
- Document time sync in audit log
Vouch server configuration is done via environment variables (see Configure Vouch Server). JWT clock skew tolerance is handled automatically.
Software Updates
Update Procedure
- Download updated packages (connected environment)
# Download latest packages from packages.vouch.sh
curl -LO https://packages.vouch.sh/rpm/x86_64/vouch-server-<new-version>-1.x86_64.rpm
curl -LO https://packages.vouch.sh/rpm/x86_64/vouch-<new-version>-1.x86_64.rpm
# For container deployments
docker pull ghcr.io/vouch-sh/vouch:<new-version>
docker save ghcr.io/vouch-sh/vouch:<new-version> -o vouch-server-<new-version>.tar
- Verify signatures (connected environment)
rpm -K vouch-server-<new-version>-1.x86_64.rpm
rpm -K vouch-<new-version>-1.x86_64.rpm
-
Transfer via approved media (sneakernet)
-
Verify again (air-gapped environment)
rpm -K vouch-server-<new-version>-1.x86_64.rpm
sha256sum -c SHA256SUMS
- Apply update
For RPM installations:
# Backup database before upgrade
cp /data/vouch.db /data/vouch.db.backup.$(date +%Y%m%d)
# Upgrade package (migrations run automatically on next startup)
rpm -Uvh vouch-server-<new-version>-1.x86_64.rpm
# Restart service
systemctl restart vouch-server
# Verify health
curl -k https://auth.internal/health
For container deployments:
docker load < vouch-server-<new-version>.tar
# Update docker-compose.yml image tag, then:
docker-compose up -d
Rollback
For RPM installations:
# Restore database backup
cp /data/vouch.db.backup.YYYYMMDD /data/vouch.db
# Downgrade package
rpm -Uvh --oldpackage vouch-server-<previous-version>-1.x86_64.rpm
# Restart service
systemctl restart vouch-server
Audit Log Export
Air-gapped environments still need audit trails for compliance.
One-Way Data Diode
+-----------------+ +-------------+ +-----------------+
| Air-Gapped |---->| Data Diode |---->| SIEM |
| Vouch Server | | (hardware) | | (connected) |
| | | | | |
| UDP syslog out | | One-way | | Splunk/Datadog |
+-----------------+ +-------------+ +-----------------+
Roadmap: Built-in syslog/SIEM forwarding from
vouch-serveris not implemented yet. Use the periodic export method below (or your database backup process — see Backup and Recovery).
Periodic Export
#!/bin/bash
# Weekly audit log export script
DATE=$(date +%Y%m%d)
OUTPUT_DIR=/mnt/export
# Export audit logs from SQLite directly
sqlite3 /data/vouch.db \
".mode json" \
"SELECT * FROM auth_events WHERE created_at >= datetime('now', '-7 days');" \
> $OUTPUT_DIR/audit-$DATE.json
# Encrypt for transport
gpg --encrypt --recipient auditor@company.com \
$OUTPUT_DIR/audit-$DATE.json
# Generate checksum
sha256sum $OUTPUT_DIR/audit-$DATE.json.gpg > $OUTPUT_DIR/audit-$DATE.sha256
# Remove unencrypted
rm $OUTPUT_DIR/audit-$DATE.json
echo "Export complete: audit-$DATE.json.gpg"
Transfer encrypted exports via approved media to connected compliance systems.
Disaster Recovery
Backup Strategy
| Component | Frequency | Method | Retention |
|---|---|---|---|
| SQLite database | Daily | File copy, encrypted | 90 days |
| SSH CA keys | On change | HSM backup or split custody | Permanent |
| Configuration | On change | Git (internal) | Permanent |
| Audit logs | Continuous | Append-only storage | Per policy |
Recovery Procedure
- Stop the service
systemctl stop vouch-server
- Restore database from backup
cp /data/vouch.db.backup.YYYYMMDD /data/vouch.db
chown vouch:vouch /data/vouch.db
- Re-sync time
# Verify NTP synchronization
timedatectl status
chronyc tracking # or ntpq -p
- Start and validate
systemctl start vouch-server
curl -k https://auth.internal/health
CA Key Recovery
If CA keys are lost, all issued certificates become unverifiable.
Prevention:
- Store CA keys in HSM with backup
- Use split-knowledge for key recovery
- Document key ceremony procedures
Recovery:
- Generate new CA from backup
- Re-provision all user credentials
- Redistribute new CA public key
- Update all SSH server trust anchors
Security Considerations
Network Segmentation
+-------------------------------------------------------------+
| Air-Gapped Network |
| |
| +-----------------+ +-----------------------------+ |
| | Management | | User Network | |
| | VLAN | | | |
| | | | +-------+ +-----------+ | |
| | * Vouch Server |<------>| |Workst.| | Protected | | |
| | * SQLite | | +-------+ | Resources | | |
| | | | +-----------+ | |
| +-----------------+ +-----------------------------+ |
| | |
| | Restricted |
| v |
| +-----------------+ |
| | Admin Jumpbox | <-- Physical access control |
| +-----------------+ |
+--------------------------------------------------------------+
Physical Security
- Server room access controls
- YubiKey storage procedures
- Media transfer protocols
- Tamper-evident logging
Compliance Mapping
| Requirement | NIST 800-53 | Implementation |
|---|---|---|
| Hardware auth | IA-2(1) | FIDO2 with YubiKey |
| Credential lifetime | IA-5(1) | 8-hour certificates |
| Audit logging | AU-2, AU-3 | All credential issuance logged |
| Time sync | AU-8 | GPS/NTP infrastructure |
| Key management | SC-12 | HSM or split-custody |
Troubleshooting
Cannot Connect to Vouch Server
# Check network connectivity
ping auth.internal
# Verify TLS
openssl s_client -connect auth.internal:443 -CAfile /etc/vouch/root-ca.crt
# Check server logs (systemd)
journalctl -u vouch-server --since "1 hour ago"
# Check server logs (Docker)
docker-compose logs vouch-server
Certificate Validation Failures
# Check system time
date
timedatectl status
# Verify CA is trusted
ssh-keygen -L -f /path/to/cert # View certificate details
# Check certificate dates
ssh-keygen -L -f /path/to/cert | grep Valid
YubiKey Not Recognized
# Check USB connection
lsusb | grep Yubico
# Verify FIDO2 functionality
ykman fido info
# Reset FIDO2 application (destructive - re-enrollment required)
ykman fido reset
Environment Variables
Every server setting listed here is available three ways: as a VOUCH_-prefixed environment
variable, as an equivalent --kebab-case command-line flag, and as a field in the
S3 configuration document. An explicit flag beats the environment variable;
S3 configuration beats both. See Configuration Sources.
A few variables the server reads are not VOUCH_-prefixed — RUST_LOG, the OTEL_* and AWS_*
families, and DSQL_USER. They are listed in their relevant sections below.
Core Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_RP_ID | No | localhost | Relying Party ID (domain, e.g. auth.example.com). Used as the WebAuthn RP ID. The default only works for local development — set it for any real deployment, because WebAuthn credentials are bound to it and changing it later invalidates every enrolled authenticator. |
VOUCH_RP_NAME | No | Vouch | Relying Party display name shown in browser prompts and UI. |
VOUCH_DATABASE_URL | No | sqlite:vouch.db?mode=rwc | Database connection URL. Supports sqlite:, postgres:, and Aurora DSQL endpoints. The default creates a SQLite file in the process working directory — set it explicitly so the database does not land somewhere transient. |
VOUCH_JWT_SECRET | Conditional | (empty) | JWT signing secret. Must be at least 32 characters. Must not consist of a single repeated character. Used to sign internal state tokens. Required unless VOUCH_JWT_HMAC_KMS_KEY_ID is set. |
VOUCH_BASE_URL | No | https://{rp_id} | Base URL for this server. Auto-derived from VOUCH_RP_ID if not set (http://localhost:{port} for local dev, https://{rp_id} for production). |
VOUCH_ORG_NAME | No | (none) | Organization name for branding in the UI. Falls back to VOUCH_RP_NAME if not set. |
VOUCH_ALLOWED_DOMAINS | No | (none) | Comma-separated list of allowed email domains for enrollment (e.g., example.com,corp.example.com). If not set, all domains are allowed. Normalized to lowercase. |
Network
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_LISTEN_ADDR | No | [::]:3000 | Address and port to listen on. Ignored when TLS is configured — the server then binds 443 and 80 unconditionally. |
VOUCH_MTLS_PORT | No | 8443 | Port for the mTLS listener used by RFC 8705 certificate-bound tokens. The listener starts automatically whenever TLS is configured; there is no flag to disable it, and a bind failure here is fatal. |
VOUCH_TRUSTED_PROXIES | No | (empty) | Comma-separated CIDRs of trusted reverse proxies (e.g. 10.0.0.0/8). When empty, X-Forwarded-For is ignored entirely and the TCP peer is treated as the client — which behind a load balancer means every user shares one rate-limit bucket. An invalid CIDR is a fatal startup error. See Behind a Reverse Proxy. |
VOUCH_EXTRA_CA_CERTS | No | (none) | Path to a PEM bundle of additional certificate authorities for the server’s outbound HTTPS client. Needed when your IdP, or another service the server calls, uses an internal CA. An unreadable file is a fatal startup error. |
Upstream Identity Provider
Configure one or more upstream IdPs (OIDC, SAML, or any mix) as a single unified list. VOUCH_IDPS holds a comma-separated list of slugs; each slug picks up its VOUCH_IDP_<SLUG>_* variables. Slugs match [a-z0-9-]{1,32} (no leading or trailing hyphen) and must be unique.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_IDPS | Yes | (none) | Comma-separated list of IdP slugs in display order (e.g., google,entra,corp-saml). At least one slug is required; the server refuses to start otherwise. |
VOUCH_IDP_<SLUG>_TYPE | Yes (per IdP) | (none) | oidc or saml. |
Hyphens in slugs become underscores in variable names: a slug of corp-saml becomes VOUCH_IDP_CORP_SAML_*.
OIDC IdP (per slug)
OIDC IdPs auto-discover authorization, token, and JWKS endpoints from {issuer}/.well-known/openid-configuration at startup.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_IDP_<SLUG>_ISSUER | Yes | (none) | OIDC issuer URL (e.g., https://accounts.google.com). Must serve a valid OIDC discovery document. |
VOUCH_IDP_<SLUG>_CLIENT_ID | Yes | (none) | OIDC client ID from the IdP. |
VOUCH_IDP_<SLUG>_CLIENT_SECRET | Yes | (none) | OIDC client secret from the IdP. |
SAML IdP (per slug)
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_IDP_<SLUG>_METADATA_URL | Yes | (none) | URL to the SAML IdP metadata XML document. Fetched at server startup. |
VOUCH_IDP_<SLUG>_SP_ENTITY_ID | No | {VOUCH_BASE_URL} | SP entity ID sent in authentication requests. Defaults to the server’s base URL. |
VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTE | No | (auto-detect) | SAML attribute name containing the user’s email address. |
VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTE | No | (none) | SAML attribute name containing the user’s domain (for domain restriction). |
Session
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_SESSION_HOURS | No | 8 | Session duration in hours. After this time, the user must re-authenticate. |
VOUCH_DEVICE_CODE_EXPIRES | No | 600 | Device code expiration in seconds. How long a device code remains valid during enrollment. |
VOUCH_DEVICE_POLL_INTERVAL | No | 5 | Device code polling interval in seconds. How frequently the CLI polls for device code completion. |
VOUCH_SESSION_CACHE_MAX_CAPACITY | No | 10000 | Maximum entries in the in-memory session lookup cache. |
VOUCH_SESSION_CACHE_TTL_SECS | No | 30 | How long a cached session lookup stays valid. Raising it reduces database reads; lowering it shortens the window in which a revoked session is still honored by an instance. |
SSH CA
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_SSH_CA_KEY | No | (none) | SSH CA private key content (Ed25519, OpenSSH format). Accepts either raw PEM or base64-encoded PEM — the server detects which by looking for the -----BEGIN header. If set, takes precedence over VOUCH_SSH_CA_KEY_PATH. |
VOUCH_SSH_CA_KEY_PATH | No | ./ssh_ca_key | Path to SSH CA private key file (raw PEM). Set to an empty string to disable the SSH CA entirely. If the file does not exist, the server generates a new Ed25519 CA key and writes it to this path — see the warning below. |
Warning: because a missing
VOUCH_SSH_CA_KEY_PATHfile causes the server to generate a new CA key, starting on a fresh or unmounted volume silently rotates your SSH CA. Every host’sTrustedUserCAKeysentry then stops matching and users cannot log in with newly issued certificates. Either provision the key file before first start, or supply the key throughVOUCH_SSH_CA_KEY/VOUCH_SSH_CA_KMS_KEY_ID, which never auto-generate.
OIDC Signing
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_OIDC_SIGNING_KEY | No | (auto-generate) | OIDC signing key content (base64-encoded PEM format, P-256 ECDSA). Used for signing access tokens and ID tokens with ES256 algorithm. If not set, an ephemeral key is generated on each server restart, and issued tokens fail verification after a restart or across instances. |
VOUCH_OIDC_RSA_SIGNING_KEY | No | (auto-generate) | OIDC RSA signing key content (base64-encoded PEM format, RSA-3072). Used for signing ID tokens with RS256 algorithm per OIDC Core Section 3.1.3.7 and all AWS credential tokens (/v1/credentials/aws/token, serving both STS AssumeRoleWithWebIdentity and IAM Identity Center CreateTokenWithIAM). Minimum 3072-bit key enforced. If not set, an ephemeral key is generated on each server restart — AWS token verification then breaks after restarts and across multiple instances, so any deployment using the AWS integration must set this (or the KMS variant). |
AWS KMS
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_SSH_CA_KMS_KEY_ID | No | (none) | AWS KMS key ID for SSH CA signing (Ed25519). When set, overrides VOUCH_SSH_CA_KEY and VOUCH_SSH_CA_KEY_PATH. |
VOUCH_OIDC_SIGNING_KMS_KEY_ID | No | (none) | AWS KMS key ID for OIDC token signing (P-256 ECDSA). When set, overrides VOUCH_OIDC_SIGNING_KEY. |
VOUCH_OIDC_RSA_SIGNING_KMS_KEY_ID | No | (none) | AWS KMS key ID for OIDC RSA token signing (RSA-3072, RSASSA_PKCS1_V1_5_SHA_256). When set, overrides VOUCH_OIDC_RSA_SIGNING_KEY. |
VOUCH_JWT_HMAC_KMS_KEY_ID | No | (none) | AWS KMS key ID for HMAC state token signing. When set, VOUCH_JWT_SECRET is not required. |
DPoP
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_DPOP_MAX_AGE | No | 300 | Maximum age of DPoP proofs in seconds. Proofs older than this are rejected. |
Cleanup & Retention
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_CLEANUP_INTERVAL | No | 15 | Background cleanup task interval in minutes. Set to 0 to disable automatic cleanup. |
VOUCH_AUTH_EVENTS_RETENTION_DAYS | No | 90 | Retention period for authentication events in days. Events older than this are purged during cleanup. |
VOUCH_OAUTH_EVENTS_RETENTION_DAYS | No | 90 | Retention period for OAuth usage and credential-issuance (aws_credential, github_credential, ssh_credential, token_exchange) events in days. Events older than this are purged during cleanup. |
CORS
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_CORS_ORIGINS | No | (none) | Comma-separated list of explicit CORS allowed origins for UI routes (e.g. https://app.example.com). Empty means same-origin only. Wildcard (*) is not supported — UI routes use credentialed cookie sessions, which are incompatible with wildcard origins per the CORS spec. |
GitHub App
These variables configure the Vouch GitHub App integration for issuing GitHub tokens. The App ID, name, and key are required together for GitHub App functionality. OAuth client ID and secret are additionally needed for GitHub user authentication.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_GITHUB_APP_ID | No | (none) | GitHub App ID (numeric, assigned when creating the app on github.com). |
VOUCH_GITHUB_APP_NAME | No | (none) | GitHub App name (the slug from github.com/apps/{name}). |
VOUCH_GITHUB_APP_KEY | No | (none) | GitHub App private key (PEM format, RSA). Can use literal \n for newlines. |
VOUCH_GITHUB_WEBHOOK_SECRET | No | (none) | GitHub webhook secret for verifying webhook signatures (HMAC-SHA256). |
VOUCH_GITHUB_APP_CLIENT_ID | No | (none) | GitHub App Client ID for OAuth user authentication. Found in GitHub App settings (different from the numeric App ID). |
VOUCH_GITHUB_APP_CLIENT_SECRET | No | (none) | GitHub App Client Secret for OAuth user authentication. |
TLS
When both VOUCH_TLS_CERT and VOUCH_TLS_KEY are set, the server listens on port 443 (HTTPS) with an automatic HTTP-to-HTTPS redirect on port 80. The VOUCH_LISTEN_ADDR setting is ignored when TLS is configured.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_TLS_CERT | No | (none) | TLS certificate (base64-encoded PEM). |
VOUCH_TLS_KEY | No | (none) | TLS private key (base64-encoded PEM). Required if VOUCH_TLS_CERT is set. |
S3 Configuration
Vouch supports loading configuration from an S3 object for centralized management. S3 configuration values override environment variables.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_S3_CONFIG_BUCKET | No | (none) | S3 bucket name for configuration file. If set, config is loaded from S3. |
VOUCH_S3_CONFIG_KEY | No | config/vouch-server.json | S3 object key for configuration file. |
VOUCH_S3_CONFIG_REGION | No | (auto) | AWS region for S3 access. Uses the default credential chain region if not set. |
VOUCH_S3_CONFIG_POLL_INTERVAL | No | 60 | S3 config polling interval in seconds. How frequently the server checks for configuration changes. |
JWT Assertion
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_JWT_ASSERTION_MAX_LIFETIME | No | 300 | Maximum lifetime (seconds) for private_key_jwt client-authentication JWT assertions (RFC 7523 §2.2 / §3). Assertions older than this are rejected. |
The signing algorithm allowed for a client’s assertion depends on its FAPI 2.0 profile, not
just this lifetime bound. Applications with fapi_profile = fapi2_security may only sign
assertions with ES256, PS256, or EdDSA (FAPI 2.0 Section 5.4.1); other applications may
additionally use RS256. Discovery’s token_endpoint_auth_signing_alg_values_supported
advertises the full four-algorithm union — an application’s own profile determines which of
those it may actually use. Setting fapi_profile = fapi2_security on an application whose
JWKS keys are all pinned to an algorithm outside that set (e.g. every key declares
"alg": "RS256") is refused with a fapi_jwks_algorithm_unsupported error, since the
application would otherwise be unable to authenticate at all after the change.
Protected Resource Metadata (RFC 9728)
These optional variables configure descriptive metadata published in the OAuth 2.0 Protected Resource Metadata document at /.well-known/oauth-protected-resource.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_RESOURCE_NAME | No | Vouch | Human-readable name of this protected resource. |
VOUCH_RESOURCE_DOCUMENTATION | No | https://vouch.sh/docs/ | URL of developer documentation for this protected resource. |
VOUCH_RESOURCE_POLICY_URI | No | https://vouch.sh/privacy/ | URL of the resource’s data-use policy. |
VOUCH_RESOURCE_TOS_URI | No | https://vouch.sh/terms/ | URL of the resource’s terms of service. |
Override the last three on a self-hosted deployment. Their defaults point at Vouch’s own site, so a deployment that leaves them alone publishes Vouch’s documentation, privacy policy, and terms as its own in a document clients read to learn who operates the resource. Point them at your organization’s pages.
The same applies to the
/privacyand/termsUI routes, which are fixed redirects tovouch.sh. Override those at your reverse proxy if you need your own.
Vulnerability Disclosure (RFC 9116)
The server publishes a security.txt document at /.well-known/security.txt with Contact,
Expires (rolling, 30 days ahead), and Canonical (built from the base URL) fields.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_SECURITY_CONTACT | No | security@vouch.sh | Contact email published in security.txt. Set this on a self-hosted deployment — the default routes vulnerability reports about your deployment to Vouch’s security team. |
CLI Download URLs
These optional variables configure download links displayed in the server UI.
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_CLI_DOWNLOAD_MACOS | No | (none) | CLI download URL for macOS, displayed in the server UI. |
VOUCH_CLI_DOWNLOAD_LINUX | No | (none) | CLI download URL for Linux, displayed in the server UI. |
VOUCH_CLI_DOWNLOAD_WINDOWS | No | (none) | CLI download URL for Windows, displayed in the server UI. |
Database Tuning
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_DB_MAX_CONNECTIONS | No | 25 | Maximum size of the connection pool. Multiply by your instance count when sizing PostgreSQL’s max_connections. |
VOUCH_DB_MIN_CONNECTIONS | No | 2 | Minimum idle connections kept open. |
VOUCH_DB_IDLE_TIMEOUT_SECS | No | 300 | How long an idle connection is kept before being closed. |
VOUCH_DB_ACQUIRE_TIMEOUT_SECS | No | 5 | How long a request waits for a free connection before failing. |
DSQL_USER | No | admin | Not VOUCH_-prefixed. Database username for Aurora DSQL when the connection URL carries none. |
Authenticator Policy
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_ALLOWED_AAGUIDS | No | (empty — any) | Which authenticator models may enroll, matched against the model named in the attestation certificate. Accepts fips-only, yubikey-5, or a comma-separated list of AAGUID UUIDs. Empty means any authenticator with a valid attestation chain. A non-UUID entry is a fatal startup error. |
Regardless of these settings, software authenticators are always rejected: the none attestation
format is refused, so only hardware-backed credentials can enroll. See
Security Hardening.
Observability
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_LOG_FORMAT | No | text | Log output format: text or json. Any other value is a fatal startup error. |
VOUCH_METRICS_BEARER_TOKEN | No | (none) | Bearer token protecting GET /metrics. When unset, the metrics endpoint is not registered at all. |
RUST_LOG | No | info | Not VOUCH_-prefixed. Standard EnvFilter directive, e.g. info,vouch_server=debug. |
OTEL_EXPORTER_OTLP_ENDPOINT | No | (none) | Not VOUCH_-prefixed. OTLP/gRPC collector endpoint. When unset, span export is disabled entirely. When set but unreachable at startup, the server fails to start. |
OTEL_SERVICE_NAME | No | vouch-server | Not VOUCH_-prefixed. Service name attached to exported spans. |
AWS Environment
These are read by the AWS SDK or by Vouch’s AWS-specific resolution logic. None are
VOUCH_-prefixed.
| Variable | Used for |
|---|---|
AWS_REGION / AWS_DEFAULT_REGION | Region for KMS and S3, and for resolving dsql_endpoints |
AWS_AZ | Availability zone, checked first when resolving dsql_endpoints |
AWS_PARTITION | Partition segment (aws, aws-us-gov) when building cross-account KMS ARNs |
AWS_USE_FIPS_ENDPOINT | Whether AWS SDK clients (S3, KMS) use FIPS endpoints |
On EC2, AWS_REGION, AWS_AZ, and AWS_PARTITION fall back to IMDS
(placement/region, placement/availability-zone, services/partition) when unset —
see EC2 instance bootstrap below. AWS_PARTITION has no IMDS
equivalent on older instance generations (services/partition 404s), in which case
cross-account KMS ARN construction is skipped.
EC2 Instance Bootstrap
On EC2, vouch-server performs its own bootstrap at startup before building
ServerConfig: it reads the region, availability zone, and partition from IMDSv2, then
fetches a KEY=VALUE configuration blob from AWS Systems Manager Parameter Store
(ssm:GetParameter with decryption) and applies it as a config layer strictly below
CLI flags and process environment variables — an explicit --flag or a real env var
always wins over the parameter; only variables the operator hasn’t already set are
filled in.
- Parameter name. Read from the
VouchConfigParameterEC2 instance tag (requires the instance to have been launched with--metadata-options 'InstanceMetadataTags=enabled'). The tag is the opt-in: when it is not visible, the SSM fetch is skipped and the server keeps only the IMDS-derived instance facts, starting from CLI flags and process environment. - Format. Strict
KEY=VALUElines, one per line, with#comments and blank lines allowed — the same format systemd’sEnvironmentFile=accepts. Noexportprefix, no CRLF line endings, no quoted values; any of these is a hard startup error rather than a silent misparse. - Scope. Only variables backed by a
vouch-serverCLI flag (everyVOUCH_*variable in this reference, plusAWS_REGION/AWS_AZ/AWS_PARTITION/AWS_USE_FIPS_ENDPOINT) are read from the parameter. Anything else in the blob (for example a strayRUST_LOG) is ignored, since it was never real process environment to begin with. In particular, useAWS_REGION— notAWS_DEFAULT_REGION— in the parameter: the alias is honored only as a real environment variable, and inside the blob it is ignored in favor of the IMDS-derived region. - Never running on EC2 (IMDS unreachable), or
AWS_EC2_METADATA_DISABLED=true. Bootstrap is skipped entirely and the server starts from CLI flags and process environment only, same as a non-EC2 deployment. - On EC2 but the SSM call fails. This is treated as a startup failure (never a
silent fallback to an unconfigured server) — the log records a
VOUCH_BOOTSTRAP_FAILEDline naming the parameter and region. The unit’sRestart=alwaysretries transient failures (e.g. SSM throttling); a persistent failure means the instance never becomes healthy, which an Auto Scaling Group replaces. - Already configured via env/CLI. If the S3 config bucket is already set (the
VOUCH_S3_CONFIG_BUCKETvariable or the--s3-config-bucketflag), the IMDS probe is skipped entirely, so non-EC2 and fully env-configured deployments pay nothing.
Test Mode
| Variable | Required | Default | Description |
|---|---|---|---|
VOUCH_CERTIFICATION_TEST_TOKEN | No | (none) | Never set in production. Enables OpenID conformance test mode: registers a login-bypass route, disables all rate limiting, and relaxes the requirement for an upstream IdP. The server logs a security warning at startup when it is set. |
Startup Validation
The server refuses to start when any of the following holds. Each produces a message naming the offending variable.
| Condition | Message |
|---|---|
VOUCH_IDPS empty or unset | No upstream IdP configured. Set VOUCH_IDPS=<slug>[,<slug>...] |
A slug fails [a-z0-9-]{1,32}, or leads/trails with a hyphen | Invalid provider slug |
| Two IdPs share a slug | Duplicate IdP slug '<id>' |
A per-IdP variable is missing (_TYPE, or OIDC _ISSUER/_CLIENT_ID/_CLIENT_SECRET, or SAML _METADATA_URL) | Names the missing variable |
VOUCH_IDP_<SLUG>_TYPE is neither oidc nor saml | Invalid type |
| An IdP’s OIDC discovery or SAML metadata fetch fails | Failed to configure IdP '<id>' |
Only one of VOUCH_TLS_CERT / VOUCH_TLS_KEY is set | Partial TLS configuration: set both ... or neither. |
VOUCH_JWT_SECRET under 32 characters, and no KMS HMAC key | VOUCH_JWT_SECRET must be at least 32 characters |
VOUCH_JWT_SECRET is a single repeated character | must not consist of a single repeated character |
| Either retention variable is negative | Negative retention rejected |
VOUCH_CORS_ORIGINS contains * | Wildcard is invalid with credentialed cookie sessions |
VOUCH_ALLOWED_AAGUIDS has a non-UUID entry | Invalid VOUCH_ALLOWED_AAGUIDS |
VOUCH_LOG_FORMAT is not text or json | Invalid VOUCH_LOG_FORMAT |
VOUCH_TRUSTED_PROXIES has a malformed CIDR | Invalid CIDR in VOUCH_TRUSTED_PROXIES |
VOUCH_EXTRA_CA_CERTS file is unreadable | Read failure |
VOUCH_DATABASE_URL scheme is not sqlite:/postgres:/postgresql: | Unsupported scheme |
| A KMS key ID is set but the KMS client cannot be built | Names the key |
| S3 configuration is enabled but the object cannot be fetched or parsed | Failed to fetch S3 configuration |
| Issuer subdomains are claimed but document encryption is not configured | issuer subdomains are claimed but document encryption is not configured |
| The mTLS listener cannot bind | Failed to start mTLS listener |
A JWT secret with fewer than 8 distinct bytes produces a warning, not an error.
See Troubleshooting for what to do about each.
Localization
The server negotiates the response language per request from the Accept-Language header and the
OIDC ui_locales parameter. There is no server-side environment variable for it, and no
configuration is required.
The vouch CLI resolves its own language separately, from --lang, VOUCH_LANG, and the standard
POSIX locale variables. That is client-side; see vouch.sh/docs.
S3 Configuration Schema
The complete field reference for the S3 configuration document.
For how S3 configuration fits with environment variables and command-line flags, how to enable it, the bucket requirements, and the polling behavior, see Configuration Sources.
JSON Schema
The S3 configuration file is a JSON document with the following schema:
{
"version": 1,
"listen_addr": "0.0.0.0:443",
"rp_id": "vouch.example.com",
"rp_name": "Example Corp",
"base_url": "https://vouch.example.com",
"database_url": "postgres://...",
"dsql_endpoints": {
"us-east-1": "postgres://vouch@abc123.dsql.us-east-1.on.aws/postgres"
},
"jwt_secret": "32+ character secret",
"session_hours": 8,
"org_name": "Example Corp",
"tls": {
"cert": "<base64-encoded PEM certificate>",
"key": "<base64-encoded PEM private key>"
},
"idps": [
{
"id": "google",
"type": "oidc",
"issuer": "https://accounts.google.com",
"client_id": "...",
"client_secret": "..."
},
{
"id": "corp-saml",
"type": "saml",
"metadata_url": "https://idp.example.com/saml/metadata",
"sp_entity_id": "https://vouch.example.com",
"email_attribute": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"domain_attribute": "department"
}
],
"allowed_domains": ["example.com"],
"ssh_ca_key": "<base64-encoded PEM Ed25519 private key>",
"ssh_ca_kms_key_id": "mrk-1234abcd5678efgh",
"oidc_signing_key": "<base64-encoded PEM EC P-256 private key>",
"oidc_signing_kms_key_id": "mrk-abcd1234efgh5678",
"oidc_rsa_signing_key": "<base64-encoded PEM RSA-3072 private key>",
"oidc_rsa_signing_kms_key_id": "mrk-rsa1234abcd5678",
"jwt_hmac_kms_key_id": "mrk-5678abcd1234efgh",
"document_key": {
"kms_key_id": "mrk-<key-id>",
"encrypted_private_key": "<base64-encoded KMS ciphertext>",
"algorithm": "p384"
},
"dpop": {
"max_age_seconds": 300
},
"cors_origins": ["https://app.example.com"],
"github": {
"app_id": 12345,
"app_name": "my-vouch-app",
"app_key": "<PEM RSA private key>",
"webhook_secret": "<secret>",
"client_id": "<oauth-client-id>",
"client_secret": "<oauth-client-secret>"
},
"cleanup_interval_minutes": 15,
"auth_events_retention_days": 90,
"oauth_events_retention_days": 90,
"resource_name": "Vouch",
"resource_documentation": "https://vouch.sh/docs/",
"resource_policy_uri": "https://vouch.sh/privacy/",
"resource_tos_uri": "https://vouch.sh/terms/",
"cli_download_macos": "https://example.com/vouch-macos",
"cli_download_linux": "https://example.com/vouch-linux",
"cli_download_windows": "https://example.com/vouch-windows",
"device_code_expires_seconds": 600,
"device_poll_interval_seconds": 5
}
Field Descriptions
| Field | Type | Description |
|---|---|---|
version | integer | Schema version. Must be 1. |
listen_addr | string | Address and port to listen on (e.g., 0.0.0.0:443). |
rp_id | string | Relying Party ID (domain). Used as the WebAuthn RP ID. |
rp_name | string | Relying Party display name for browser prompts and UI. |
base_url | string | External base URL for the server. |
database_url | string | Database connection URL (sqlite:, postgres:, or Aurora DSQL). |
dsql_endpoints | object | Regional DSQL endpoints. Maps AWS region to full connection string. |
jwt_secret | string | JWT signing secret (minimum 32 characters). Not required if jwt_hmac_kms_key_id is set. |
session_hours | integer | Session duration in hours. |
org_name | string | Organization display name for branding in the UI. |
tls.cert | string | TLS certificate (base64-encoded PEM). |
tls.key | string | TLS private key (base64-encoded PEM). |
idps[] | array of objects | Configured identity providers (OIDC + SAML). Order controls login-page button order. Each entry has id, type ("oidc" or "saml"), and type-specific fields. |
idps[].id | string | Operator-chosen slug ([a-z0-9-]{1,32}, no leading/trailing hyphen, unique). Used in the state table, callback routing, and audit logs. |
idps[].type | string | "oidc" or "saml". |
idps[].issuer (OIDC) | string | OIDC issuer URL. The server auto-discovers endpoints. |
idps[].client_id (OIDC) | string | OIDC client ID from the IdP. |
idps[].client_secret (OIDC) | string | OIDC client secret from the IdP. |
idps[].metadata_url (SAML) | string | URL to the SAML IdP metadata XML document. |
idps[].sp_entity_id (SAML) | string | SP entity ID (defaults to base_url). |
idps[].email_attribute (SAML) | string | SAML attribute name for email extraction. |
idps[].domain_attribute (SAML) | string | SAML attribute name for domain extraction. |
allowed_domains | array of strings | Allowed email domains for enrollment. |
ssh_ca_key | string | SSH CA private key (base64-encoded PEM, Ed25519). |
ssh_ca_kms_key_id | string | AWS KMS key ID for SSH CA signing (Ed25519). Overrides ssh_ca_key. |
oidc_signing_key | string | OIDC signing key (base64-encoded PEM, P-256 ECDSA). |
oidc_signing_kms_key_id | string | AWS KMS key ID for OIDC token signing (P-256). Overrides oidc_signing_key. |
oidc_rsa_signing_key | string | OIDC RSA signing key (base64-encoded PEM, RSA-3072). Signs ID tokens with RS256. |
oidc_rsa_signing_kms_key_id | string | AWS KMS key ID for OIDC RSA signing (RSA-3072). Overrides oidc_rsa_signing_key. |
jwt_hmac_kms_key_id | string | AWS KMS key ID for HMAC state token signing. Overrides jwt_secret. |
document_key | object | Document encryption key. Contains kms_key_id, encrypted_private_key, and optional algorithm (default "p384", currently the only value). |
dpop.max_age_seconds | integer | Maximum age of DPoP proofs in seconds. |
cors_origins | array of strings | CORS allowed origins. |
github.app_id | integer | GitHub App ID. |
github.app_name | string | GitHub App name (slug from github.com/apps/{name}). |
github.app_key | string | GitHub App private key (PEM RSA). |
github.webhook_secret | string | GitHub webhook secret for signature verification. |
github.client_id | string | GitHub App OAuth client ID. |
github.client_secret | string | GitHub App OAuth client secret. |
cleanup_interval_minutes | integer | Background cleanup task interval in minutes. |
auth_events_retention_days | integer | Retention period for authentication events in days. |
oauth_events_retention_days | integer | Retention period for OAuth usage and credential-issuance (aws_credential, github_credential, ssh_credential, token_exchange) events in days. |
resource_name | string | Human-readable name of this protected resource (RFC 9728). Defaults to "Vouch". |
resource_documentation | string | URL of developer documentation for this protected resource (RFC 9728). Defaults to "https://vouch.sh/docs/". |
resource_policy_uri | string | URL of the resource’s data-use policy (RFC 9728). Defaults to "https://vouch.sh/privacy/". |
resource_tos_uri | string | URL of the resource’s terms of service (RFC 9728). Defaults to "https://vouch.sh/terms/". |
cli_download_macos | string | CLI download URL for macOS, displayed in the server UI. |
cli_download_linux | string | CLI download URL for Linux, displayed in the server UI. |
cli_download_windows | string | CLI download URL for Windows, displayed in the server UI. |
device_code_expires_seconds | integer | Device code expiration in seconds. |
device_poll_interval_seconds | integer | Device code polling interval in seconds. |
Reserved Keys
| Key | Owner | Description |
|---|---|---|
_acme | External certificate renewal process | ACME (Let’s Encrypt) account state: account_key (base64-encoded PEM), email, and account_uri. The renewal process reads and writes this key directly in the S3 object when it renews the certificate and updates tls.cert/tls.key. The server ignores it. |
Do not remove, rename, or hand-edit reserved keys. They are not parsed by the server, but external automation depends on them being present in the S3 object.
Base64 Encoding
All certificate and key fields in the S3 configuration must be base64-encoded PEM strings. To encode a PEM file:
# Encode a PEM file for S3 config
base64 -i cert.pem | tr -d '\n'
Base64 encoding keeps multi-line PEM content in a single JSON string value.
Hot-Reloadable vs Startup-Only Fields
Only tls.cert and tls.key are applied while the server is running. Every other field in this
document takes effect at startup only, and changes to them are ignored — silently — until the
server restarts. See
Configuration Sources.
Ports and Endpoints
Reference for writing firewall rules, security groups, and load balancer routing.
Ports
| Port | Protocol | Purpose | Configurable |
|---|---|---|---|
| 443 | HTTPS | Main listener | No — fixed whenever TLS is configured |
| 80 | HTTP | 308 redirect to HTTPS, plus /health | No |
| 8443 | HTTPS + mTLS | Client-certificate listener for RFC 8705 certificate-bound tokens | Port only, via VOUCH_MTLS_PORT |
| 3000 | HTTP | Default listener when TLS is not configured | Yes, via VOUCH_LISTEN_ADDR |
Which ports are live depends on whether VOUCH_TLS_CERT and VOUCH_TLS_KEY are set:
TLS configured — the server listens on 443 and 80 and starts the mTLS listener on 8443.
VOUCH_LISTEN_ADDR is ignored.
TLS not configured — the server listens only on VOUCH_LISTEN_ADDR (default [::]:3000).
No redirect listener, no mTLS listener.
Three things regularly surprise operators here:
- The mTLS listener has no on/off switch. It starts automatically whenever TLS is configured. A security group that opens only 80 and 443 silently breaks certificate-bound tokens; a firewall audit that flags 8443 is seeing expected behavior.
- Binding 80 and 443 needs
CAP_NET_BIND_SERVICEon Linux. The RPM and DEB packages configure it. A bind failure on port 80 is logged as a warning and is not fatal — you lose the HTTP redirect while everything else keeps working. - A bind failure on the mTLS port is fatal. Unlike port 80, it aborts startup.
Endpoints by authentication type
| Auth | What it means |
|---|---|
| None | Public, unauthenticated |
| Bearer/DPoP | A Vouch access token, in Authorization: Bearer or Authorization: DPoP |
| Signed | Bearer/DPoP plus an RFC 9421 HTTP message signature |
| Session | Browser cookie session |
| Admin | Session or Bearer, and the user must be an active org administrator |
| SCIM token | A vouch_scim_… bearer token |
| Metrics token | The VOUCH_METRICS_BEARER_TOKEN value |
| HMAC | GitHub webhook signature |
Operations
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/health | GET | None | Liveness. Returns ok as plain text. Also served on port 80 |
/health/ready | GET | None | Readiness. Checks the database; 503 when unreachable |
/metrics | GET | Metrics token | Only registered when VOUCH_METRICS_BEARER_TOKEN is set |
Discovery and metadata
| Endpoint | Method | Auth |
|---|---|---|
/.well-known/openid-configuration | GET | None |
/.well-known/oauth-authorization-server | GET | None |
/.well-known/oauth-protected-resource | GET | None |
/.well-known/security.txt | GET | None |
/oauth/jwks | GET | None |
/saml/metadata | GET | None |
OAuth and authentication
| Endpoint | Method | Auth | Rate-limit tier |
|---|---|---|---|
/oauth/token | POST | Client auth | Authentication |
/oauth/par | POST | Client auth | Authentication |
/oauth/fido2/challenge | POST | None | Authentication |
/oauth/device | POST | Client auth (RFC 8628 §3.1); enrolling requires CLI 2026.9.4 or later | Authentication |
/oauth/register | POST | None (RFC 7591) | Authentication |
/oauth/register/{client_id} | GET/PUT/DELETE | Registration access token | Authentication |
/oauth/authorize | GET | Session | General |
/oauth/introspect | POST | Client auth | General |
/oauth/revoke | POST | Client auth | General |
/oauth/userinfo | GET | Bearer/DPoP | Not limited |
/oauth/callback | GET | None (IdP redirect) | Not limited |
/saml/acs | POST | None (IdP assertion) | Not limited |
Credentials and keys
| Endpoint | Method | Auth | Rate-limit tier |
|---|---|---|---|
/v1/credentials/ssh | POST | Signed | Credential |
/v1/credentials/aws/token | GET | Signed | Credential |
/v1/credentials/github/token | POST | Signed | Credential |
/v1/credentials/ssh/ca | GET | None | General |
/v1/credentials/ssh/krl | GET | None | General |
/v1/credentials/ssh/krl/{serial} | GET | None | General |
/v1/credentials/github/status | GET | None | General |
/v1/keys | GET | Signed | General |
/v1/keys/{id} | PATCH/DELETE | Signed | General |
/v1/keys/register/start · /complete | POST | Signed | Authentication |
/v1/auth/status | GET | None | Not limited |
The public read endpoints are unauthenticated by design: SSH hosts fetch the CA public key and revocation list without holding credentials.
Administration
| Endpoint | Method | Auth | Rate-limit tier |
|---|---|---|---|
/admin and /admin/* | GET/POST | Admin | General |
/api/v1/org/scim-tokens | GET/POST | Admin | General |
/api/v1/org/scim-tokens/{id} | DELETE | Admin | General |
/api/v1/org/policies/validate | POST | Admin | General |
/scim/v2/* | GET/POST/PUT/PATCH/DELETE | SCIM token | General |
/api/v1/applications* | various | Bearer/DPoP | General |
/api/webhooks/github | POST | HMAC | General |
Admin form POSTs additionally require an Origin header matching the server’s own origin; a
mismatch is rejected with 403.
Browser UI
/, /login, /device, /install, /integrations, /enroll/*, /logout, /github/*,
/applications/*, /static/*, /favicon.ico, /i18n.js — session-based or public, HTML
responses.
/privacy and /terms are 301 redirects to vouch.sh. If you need your own legal pages, put them
in front of Vouch at your proxy.
Request limits
| Scope | Limit |
|---|---|
| Global timeout | 30 seconds (408 on expiry) |
| Global body | 256 KiB |
| Credential issuance | 8 KiB |
SCIM, /oauth/authorize, SAML ACS | 64 KiB |
| Enroll and login WebAuthn | 32 KiB |
| GitHub webhook | 1 MiB |
Outbound connections
The server itself makes outbound HTTPS calls; egress rules must allow them:
| Destination | When |
|---|---|
| Your upstream IdP (discovery, JWKS, token) | Always — at startup and during enrollment |
| Your SAML IdP metadata URL | At startup, if a SAML IdP is configured |
| AWS KMS, S3, STS | When KMS keys, S3 configuration, or the AWS integration are used |
api.github.com | When the GitHub App integration is used |
| DNS resolvers | Domain-ownership TXT verification |
An OAuth client’s jwks_uri | At dynamic client registration — restricted to public IPs by SSRF protection |
Discovery and metadata fetches happen at startup and are fatal on failure: a blocked egress
rule shows up as a server that will not boot, not as a degraded feature. Use VOUCH_EXTRA_CA_CERTS
if any of these present certificates from an internal CA.