Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 isWhere 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

Getting help

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_ID is baked into every WebAuthn credential. Changing it later invalidates every enrolled key. localhost is 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:

WarningMeaning
Using ephemeral OIDC signing keyTokens die on restart, and fail across instances. Set VOUCH_OIDC_SIGNING_KEY.
Using ephemeral OIDC RSA signing keySame, for AWS credential tokens. Set VOUCH_OIDC_RSA_SIGNING_KEY.
Generating new SSH CA keypair at ./ssh_ca_keyNo 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, then login_success, then ssh_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:

  1. TLS, Ports, and mTLS — real certificates. The server moves to ports 443 and 80, and VOUCH_LISTEN_ADDR stops applying.
  2. Signing Keys — replace both ephemeral OIDC keys with durable ones, and provision the SSH CA key explicitly instead of letting it auto-generate.
  3. Database — PostgreSQL if you will run more than one instance.
  4. 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.
  5. Security Hardening — the pre-production checklist.
  6. 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

MethodBest for
SystemdBare metal, VMs, single-node
DockerContainer-based deployments
KubernetesMulti-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

ComponentMinimumRecommended
CPU1 vCPU2 vCPU
Memory256 MB512 MB
Disk1 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

  1. Database Setup — Choose and configure your database
  2. TLS Configuration — Set up HTTPS
  3. Configuration Reference — Full environment variable reference
  4. 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 the vouch user, mode 700)

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:

  1. Copy the binary:

    sudo cp vouch-server /usr/bin/
    sudo chmod 755 /usr/bin/vouch-server
    
  2. 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
    
  3. 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
    
  4. 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:

  1. 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
    
  2. 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
    
  3. 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:

ProbeEndpointBehavior
Liveness/healthReturns 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/readyRuns 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 /health readiness 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

  1. S3 configuration (highest) — a JSON object fetched from S3 at startup
  2. Command-line arguments--kebab-case flags passed to vouch-server
  3. Environment variablesVOUCH_* 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:

RequirementWhy
Server-side encryptionThe document holds secrets at rest
Block Public AccessIt must never be reachable anonymously
Least-privilege IAMThe server needs only s3:GetObject and s3:HeadObject
VersioningGives you rollback and a change trail
Access loggingLets 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 HEAD request checks for a change before any full GET.
  • 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 VariableKey TypeReplaces
VOUCH_SSH_CA_KMS_KEY_IDEd25519 (ECC_EDWARDS_CURVE_25519)VOUCH_SSH_CA_KEY / VOUCH_SSH_CA_KEY_PATH
VOUCH_OIDC_SIGNING_KMS_KEY_IDP-256 (ECC_NIST_P256)VOUCH_OIDC_SIGNING_KEY
VOUCH_OIDC_RSA_SIGNING_KMS_KEY_IDRSA-3072 (RSA_3072)VOUCH_OIDC_RSA_SIGNING_KEY
VOUCH_JWT_HMAC_KMS_KEY_IDHMAC-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

FieldHot-ReloadableNotes
tls.cert, tls.keyYesAutomatic reload on change
All other fieldsNoRequires 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:

  1. Via S3 polling — Update tls.cert and tls.key in S3 config; server detects change via ETag and reloads
  2. Via SIGHUP — Send SIGHUP to 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:

  1. Create a PostgreSQL database:

    CREATE DATABASE vouch;
    CREATE USER vouch WITH PASSWORD 'secure-password';
    GRANT ALL PRIVILEGES ON DATABASE vouch TO vouch;
    
  2. Configure the connection:

    export VOUCH_DATABASE_URL="postgres://vouch:secure-password@db.example.com:5432/vouch"
    
  3. 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

DatabaseBackup MethodFrequency
SQLiteFile copy (cp vouch.db vouch.db.backup)Daily
PostgreSQLpg_dumpDaily
Aurora DSQLAWS automated backupsContinuous

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.

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 /health endpoint accessible on HTTP (for load balancer health checks)
  • Validates the Host header against rp_id to prevent injection attacks
  • Ignores VOUCH_LISTEN_ADDR (ports are fixed at 443/80)

Note: Binding to ports 80 and 443 requires CAP_NET_BIND_SERVICE capability 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:

SituationClient IP used
No trusted proxies configuredTCP peer address; X-Forwarded-For ignored
Peer is not in the trusted setTCP peer address; X-Forwarded-For ignored (fail closed)
Peer is trusted, header presentFirst right-to-left X-Forwarded-For entry outside the trusted set
Peer is trusted, header absent or emptyTCP peer address
Peer is trusted, every entry trustedTCP peer address
Peer is trusted, an entry is unparseableThe 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:

  • nginxproxy_set_header Host $host;
  • HAProxy — preserved by default
  • AWS NLB (TCP passthrough) — no HTTP layer, so Host is 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:

PortPurposeConfigurable
443HTTPSNo — fixed when TLS is configured
80HTTP→HTTPS redirect (308), plus /healthNo
8443mTLS listener for certificate-bound tokensYes — 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

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.

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.

ListenerTarget groupCarries
TCP 443TCP 443HTTPS
TCP 80TCP 80HTTP, which Vouch answers with a 308 to HTTPS
TCP 8443TCP 8443mTLS, 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_PROXIES is 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_PROXIES covers your proxy ranges, and nothing wider
  • The proxy forwards the original Host
  • The proxy appends to X-Forwarded-For rather than replacing it

Either way:

  • Health checks target /health/ready, not /health
  • VOUCH_BASE_URL is the public URL clients use
  • Proxy timeouts are at least 30 seconds
  • Audit events at /admin/audit show 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:

ProtocolUse Case
OIDC (OpenID Connect)Recommended for most deployments. Supports auto-discovery of endpoints.
SAML 2.0For 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

ProviderProtocolGuide
Google WorkspaceOIDCGoogle Workspace (OIDC)
Microsoft Entra IDOIDC or SAMLEntra ID (OIDC), SAML 2.0
OktaOIDC or SAMLGeneric OIDC, SAML 2.0
KeycloakOIDC or SAMLGeneric OIDC, SAML 2.0
Auth0OIDCGeneric OIDC
Any OIDC-compliant providerOIDCGeneric OIDC
Any SAML 2.0-compliant providerSAMLSAML 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 ClaimVouch AttributeRequired
emailUser email / principalYes
email_verifiedEmail verification statusYes (must be true)
subUpstream subject, bound to the account together with the token issuer (see Account linking)Yes
hdGoogle Workspace hosted domainNo (Google-specific)
tidEntra tenant IDNo (Entra-specific, cross-checked against issuer UUID to prevent cross-tenant token injection)

SAML Attributes

SAML AttributeVouch AttributeNotes
Configurable via VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTEUser email / principalFalls back to NameID if not found
Configurable via VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTEDomain for enrollment restrictionExtracted 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:

  1. 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.
  2. 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.
  3. 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_refused audit 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.
  4. 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

  1. Go to Google Cloud Console
  2. Select or create a project
  3. Navigate to APIs & Services > Credentials
  4. Click Create Credentials > OAuth client ID
  5. Select Web application as the application type
  6. Configure:
    • Name: Vouch
    • Authorized redirect URIs: https://auth.example.com/oauth/callback
  7. Click Create
  8. Copy the Client ID and Client Secret
  1. Navigate to APIs & Services > OAuth consent screen
  2. Select Internal (restricts to your Google Workspace org)
  3. Configure:
    • App name: Vouch
    • User support email: your admin email
    • Authorized domains: your Vouch server domain
  4. 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

  1. Run vouch enroll on a workstation
  2. The browser redirects to Google sign-in
  3. After signing in, complete the WebAuthn registration with your YubiKey

Claims Mapping

Google ClaimVouch Attribute
emailUser email / principal
nameDisplay name
email_verifiedMust 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_DOMAINS to 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:

  1. Sign in to the Azure portal
  2. Navigate to Microsoft Entra ID > App registrations > New registration
  3. 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

  4. Click Register

Step 2: Create a Client Secret

  1. In the app registration, go to Certificates & secrets > Client secrets
  2. Click New client secret
  3. Set a description and expiry period
  4. 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:

  1. In the app registration, go to Token configuration > Add optional claim
  2. Select token type ID
  3. Check xms_edov
  4. Click Add
  5. 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

  1. Run vouch enroll on a workstation
  2. The browser redirects to the Microsoft sign-in page
  3. 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-configuration endpoint 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/callback must 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:

ProviderIssuer URL Format
Oktahttps://{your-domain}.okta.com or https://{your-domain}.okta.com/oauth2/{auth-server-id}
Keycloakhttps://{host}/realms/{realm}
Auth0https://{tenant}.auth0.com/
Google Workspacehttps://accounts.google.com
Entra IDhttps://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:

ProviderNotes
Google WorkspaceSee dedicated guide
Microsoft Entra IDSee dedicated guide
OktaUse the Org Authorization Server or a custom one
KeycloakRequires a configured realm with client credentials
Auth0Use 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 issuer field in the discovery document must exactly match the configured VOUCH_IDP_<SLUG>_ISSUER value (trailing slashes matter). Entra /organizations/v2.0 is special-cased automatically — its discovery document returns a {tenantid} template that vouch handles transparently. The /common/v2.0 endpoint 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.

VariableRequiredDefaultDescription
VOUCH_IDP_<SLUG>_TYPEYes(none)Must be saml for a SAML IdP.
VOUCH_IDP_<SLUG>_METADATA_URLYes(none)URL to the IdP’s SAML metadata XML document. Fetched at server startup.
VOUCH_IDP_<SLUG>_SP_ENTITY_IDNo{VOUCH_BASE_URL}SP entity ID sent in authentication requests. Defaults to the server’s base URL.
VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTENo(auto-detect)SAML attribute name containing the user’s email address.
VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTENo(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 of VOUCH_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 CaseAttribute Example
Standard emailhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
NameID(used automatically if no attribute match)
CustomSet 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 missing Format attribute — the SAML spec gives no stability guarantee for these, and some IdPs mint a new value per login under this format without declaring transient. 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/acs and 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_ATTRIBUTE to 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

KeyAlgorithmPurposeStorage
SSH CA KeyEd25519Signs SSH user certificatesFile, env var, S3 config, or KMS
OIDC Signing KeyP-256 EC (ES256)Signs access tokens and ID tokens (default)Env var, S3 config, or KMS
OIDC RSA Signing KeyRSA-3072 (RS256)Signs ID tokens (per-client, OIDC Core conformance)Env var, S3 config, or KMS
JWT SecretHMAC-SHA256Signs internal state tokens (authorization codes, WebAuthn state, CSRF)Env var, S3 config, or KMS
Document Encryption KeyP-384 EC (HPKE)Encrypts sensitive documents stored alongside S3 configS3 config (KMS-protected)
TLS CertificateEC/RSAHTTPS transportEnv var or S3 config
Client Key (per-CLI)P-256 EC (ES256)FAPI 2.0 client auth, DPoP proofsOS 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_PATH does not exist, the server generates a new Ed25519 CA key and writes it there (mode 0600, comment vouch-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’s TrustedUserCAKeys entry stops matching and users can no longer log in with newly issued certificates, while /health stays green. Prefer VOUCH_SSH_CA_KEY or VOUCH_SSH_CA_KMS_KEY_ID for 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:

  1. Generate a new CA key
  2. Distribute the new public key to all hosts (add to TrustedUserCAKeys)
  3. Update the Vouch server configuration with the new private key
  4. Restart the server
  5. 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 TrustedUserCAKeys until 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 sshd expects 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 than openssl 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 it PRIVATE KEY, while SEC1 labels it EC 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:

  1. Generate a new key
  2. Update the server configuration
  3. Restart the server
  4. The JWKS endpoint (/oauth/jwks) automatically serves the new public key
  5. 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:

  1. Generate a new RSA-3072 key
  2. Update the server configuration
  3. Restart the server
  4. The JWKS endpoint (/oauth/jwks) automatically serves the new public key
  5. 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.

  1. Generate a new secret or KMS key
  2. Update VOUCH_JWT_SECRET or VOUCH_JWT_HMAC_KMS_KEY_ID
  3. Restart the server
  4. All users must run vouch login again

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:

  1. Provision a new document_key with the new algorithm (one new generate-document-key --algorithm value).
  2. 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.
  3. 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.

PagePathCovered in
Members/adminThis page
Audit log/admin/auditAudit Events
Posture policies/admin/policiesPosture Policies
SCIM tokens/admin/scim-tokensSCIM Provisioning
Email domains/admin/domainsEmail Domains

Member actions

All of these are on the Members page, and every one writes an audit event.

ActionEffect
PromoteGrants administrator rights. Audited as admin_promote.
DemoteRemoves administrator rights; the account otherwise keeps working. Audited as admin_demote.
DeactivateMarks 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.
ActivateReverses a deactivation. The user must sign in again — deactivation already destroyed their sessions. Audited as admin_activate.
Revoke credentialsDeletes 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.
RemoveRevokes 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 YubiKeyRevoke 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 compromiseDeactivate. It cuts off access immediately and is reversible, because their authenticators survive.
  • Someone has left the organizationRemove, 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:

DecisionWhenPolicies that apply
Token issuancevouch login (FIDO2 assertion grant)Device posture, plus history policies that count prior activity
Token exchangeWorkload 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 at vouch 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.

SlugNameRequires
disk_encryptionDisk EncryptionFull-disk encryption enabled (FileVault, BitLocker, LUKS)
firewallFirewallAn active firewall
screen_lockScreen LockScreen lock on idle enabled
endpoint_protectionEndpoint ProtectionAt least one EDR agent installed
mdm_enrollmentMDM EnrollmentAt least one MDM agent detected (Jamf, Kandji, Intune, …)
platform_integrityPlatform IntegritySecure Boot enabled
os_recencyOS RecencymacOS 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:

SlugNameDenies when
issuance_rate_limitIssuance Rate LimitThe user obtained 10 or more tokens in the past hour
exchange_rate_limitExchange Rate LimitThe user performed 30 or more token exchanges in the past hour (exchange only)
failed_login_burstFailed Login BurstThe user had 5 or more failed logins in the past ten minutes
token_exchange_step_upToken Exchange Step-UpNo successful hardware login in the past 15 minutes (exchange only)
exchange_ip_consistencyExchange IP ConsistencyNo successful login from this IP address in the past 8 hours (exchange only)
logout_invalidates_exchangeLogout Invalidates ExchangeThe 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_recency denies 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:

  1. 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.
  2. Checksdevice 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.
  3. 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 like 15.3 and emit the numeric os_version_num encoding 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.

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

EventMatchable fields
Vouch::Action::"Login"::responseinput.ip, input.user_agent, output.result (boolean)
Vouch::Action::"IssueToken"::responseinput.ip, input.client_id
Vouch::Action::"ExchangeToken"::responseinput.ip, input.client_id, input.audience
Vouch::Action::"Logout"::responsenone — the event itself is the signal
Vouch::Action::"RevokeToken"::responsenone
Vouch::Action::"IssueCredential"::responseinput.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_numos_version encoded as major*1000000 + minor*1000 + patch ("15.3.1"15003001; 4-component Windows versions encode as -1). os_build_numos_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

EventTrigger
policy_deniedA policy denied token issuance or exchange
admin_policy_createCustom policy created
admin_policy_updateCustom policy edited
admin_policy_deleteCustom policy deleted
admin_policy_toggleAny 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

  1. Add the domain on /admin/domains. The server generates a random token and the entry enters the Pending state.

  2. 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.com that is _vouch-verification.example.com.

  3. Click Verify. The server performs a DNS TXT lookup and marks the entry Verified if 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

StateMeaningCounts for login matching
PendingAdded, TXT record never yet observedNo
VerifiedOwnership confirmedYes
UnverifiedWas verified, then failed re-verification repeatedlyNo

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_unverified audit 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:

EntryDeleted afterAudit event
Pending — added but never verified7 daysorg_domain_expired
Unverified — auto-unverified by failed re-checks14 daysorg_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

EventTrigger
org_domain_addedDomain added, entering pending
org_domain_verifiedTXT record matched, entry verified
org_domain_removedAdministrator removed the domain
org_domain_unverifiedRe-verification failed 3 times in a row
org_domain_expiredGarbage-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:

ResourceAttribute pathadd / replaceremove
Useractivesets it; a non-boolean is rejectedrejected (mutability)
Username.formatted, displayNamesets the stored nameclears it
UserexternalIdsets itclears it
UseruserName, 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)
GroupdisplayNamesets it; empty is rejectedrejected (mutability)
GroupexternalIdsets itclears it
Groupmembersadd adds members; replace swaps the whole setremoves every member
Groupmembers[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:

RequestscimType
remove with no pathnoTarget
replace of members[value eq "<user-id>"] when that user is not a membernoTarget
A members filter other than value eq "<user-id>"invalidFilter
add or replace with no value, or a member entry without a string valueinvalidValue

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:

ResourceAttributePresentOmitted
UseruserNamemust be the stored email, or 400 mutability400 invalidSyntax (required)
Useremailsevery value must be the stored email, or 400 mutabilityleft as is
Usernamestored (formatted, or givenName and familyName joined)cleared
UserexternalIdstoredcleared
Useractivestoredset to true
GroupdisplayNamestored; empty is 400 invalidValue400 invalidSyntax (required)
GroupexternalIdstoredcleared
Groupmembersreplaces the whole member setevery 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 active makes the user active. A PUT with "active": false deactivates 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 members empties 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:

CauseStatusscimType
Body is not valid JSON400invalidSyntax
Body omits a required attribute (userName, Group displayName)400invalidSyntax
Body is JSON but an attribute has the wrong type or an empty required value400invalidValue
Query parameter has the wrong type (startIndex=abc)400invalidValue
Content-Type is not JSON (application/scim+json and application/json both work)415
Body over 64 KiB413
Resource id that does not exist, including one that is not a UUID404
Path that is not a SCIM endpoint404
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):

ActionTimingEffect
Active sessions invalidatedImmediateAll current sessions for the user are terminated
SSH certificates revokedImmediateAll issued SSH certificates are marked as revoked
Enrolled authenticators deletedImmediateAll registered credentials are removed (cascade)
User record deletedImmediateUser cannot re-enroll or authenticate
Audit event loggedImmediateDe-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 Authorization header
  • 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:

OperationResource TypeLogged Data
createUserresource_id, scim_token_id, timestamp
updateUserresource_id, scim_token_id, timestamp (PATCH)
replaceUserresource_id, scim_token_id, timestamp (PUT)
deleteUserresource_id, scim_token_id, timestamp
createGroupresource_id, display_name, scim_token_id, timestamp
updateGroupresource_id, scim_token_id, timestamp (PATCH)
replaceGroupresource_id, scim_token_id, timestamp (PUT)
deleteGroupresource_id, scim_token_id, timestamp

SCIM vs Manual Enrollment

AspectSCIM ProvisioningManual Enrollment
User record creationIdP pushes user infoUser initiates enrollment
Hardware enrollmentStill requires physical hardware keyRequires physical hardware key
De-provisioningImmediate via IdP (user deleted, sessions invalidated, certs revoked)Manual admin action (sessions invalidated, certs revoked)
Group membershipSynced from IdPNot 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 TypeDescription
login_successUser authenticated — FIDO2 passkey login, or a returning user signing in on the website via the upstream IdP (the latter has no authenticator_id)
login_failedFailed authentication attempt
enrollmentUser enrolled their first hardware key
logoutUser logged out (including RFC 7009 token revocation)
key_registeredAdditional hardware key registered (vouch register)
key_removedHardware key removed
key_renamedHardware key renamed
device_auth_approvedBrowser approved a CLI device-authorization request
key_registration_replayReplayed key-registration link rejected (possible attack)
identity_boundUpstream IdP identity (issuer + subject) bound to an account on its first IdP login; data.idp_issuer names the issuer
identity_bind_refusedIdP 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 TypeDescription
ssh_credentialSSH certificate issued; data includes the serial, principals, requesting agent, and expiry
aws_credentialAWS OIDC token issued; data includes the pinned IAM role_arn (the https://aws.amazon.com/roles claim), the requesting agent, and token expiry
github_credentialGitHub installation token issued or installation connected; data includes repositories and permissions
token_exchangeRFC 8693 token exchange (workload identity federation); data includes the client, audience, scope, and issued token type

OAuth clients

Event TypeDescription
oauth_token_issuedToken issued at /oauth/token (data.details carries the grant type)
oauth_token_revokedAll tokens for an application revoked
oauth_client_registeredOAuth client registered (RFC 7591 or applications UI)
oauth_client_updatedOAuth client configuration updated
oauth_client_deletedOAuth client deleted
oauth_secret_addedClient secret added
oauth_secret_revokedClient secret revoked

Administration and organization

Event TypeDescription
scim_operationSCIM provisioning operation (data carries operation and resource type)
admin_promoteOrg-admin role granted
admin_demoteOrg-admin role removed
admin_deactivateUser account deactivated
admin_activateUser account reactivated
admin_revoke_credentialsAdmin revoked a member’s keys, sessions, and certificates
admin_remove_userAdmin removed a member from the organization
policy_deniedA posture or temporal policy denied credential issuance
admin_policy_togglePosture policy enabled or disabled
admin_policy_createCustom posture policy created
admin_policy_updateCustom posture policy updated
admin_policy_deleteCustom posture policy deleted
admin_create_scim_tokenSCIM API token created
admin_delete_scim_tokenSCIM API token deleted
admin_revoke_scim_tokenSCIM API token revoked
org_domain_addedAdditional email domain added to the organization
org_domain_verifiedAdditional email domain ownership verified
org_domain_removedAdditional email domain removed by an admin
org_domain_expiredStale additional domain removed by the cleanup task (never verified, or unverified past its TTL)
org_domain_unverifiedVerified additional domain flipped to unverified after repeated DNS re-check failures
org_subdomain_claimedIssuer subdomain claimed for the organization
org_subdomain_releasedIssuer subdomain released (by an admin, or automatically when its backing domain became unverified)
org_issuer_key_rotatedPer-org issuer signing keys rotated (one event per algorithm)
org_issuer_key_revokedPer-org previous signing keys revoked (one event per algorithm)
org_issuer_key_emergency_rotationEmergency 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.

ClassGoverned byContains
AuthenticationVOUCH_AUTH_EVENTS_RETENTION_DAYS (default 90)Logins, enrollment, logout, key and device-auth lifecycle, SCIM operations
OAuth and credentialsVOUCH_OAUTH_EVENTS_RETENTION_DAYS (default 90)Credential issuance, token issue/revoke, client registration — the high-volume events
Kept forevernothing — never deletedEvery 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:read scope — 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 via POST /api/v1/org/scim-tokens with "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: Bearer or DPoP), 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

ParameterDescription
event_typeComma-separated list of event types (e.g. login_success,login_failed). Unknown or empty values return 400 rather than silently matching nothing.
user_idExact match.
emailExact match (HMAC lookup, case-insensitive).
since / untilRFC 3339 timestamps; only events strictly after since and strictly before until.
afterForward 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.
beforeBackward cursor: the id of the last event from a previous page. Returns events in descending (newest-first) order, matching /admin/audit.
limitPage size, default 500, maximum 1000.
formatocsf 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 TypeOCSF Classactivity_idactivity_name
admin_promoteAccount Change (3001)99Admin Promote
admin_demoteAccount Change (3001)99Admin Demote
admin_revoke_credentialsAccount Change (3001)99Admin Revoke Credentials
identity_boundAccount Change (3001)99Identity Bound
key_renamedAccount Change (3001)99Key Renamed
oauth_token_revokedAuthorize Session (3003)99OAuth Token Revoked
scim_operationEntity Management (3004)99SCIM Operation
Event TypeOCSF Class UIDOCSF Class Name
login_success3002Authentication
login_failed3002Authentication
logout3002Authentication
device_auth_approved3002Authentication
identity_bind_refused3002Authentication
enrollment3001Account Change
identity_bound3001Account Change
key_registered3001Account Change
key_removed3001Account Change
key_renamed3001Account Change
key_registration_replay3001Account Change
admin_promote3001Account Change
admin_demote3001Account Change
admin_activate3001Account Change
admin_deactivate3001Account Change
admin_revoke_credentials3001Account Change
admin_remove_user3001Account Change
ssh_credential3003Authorize Session
aws_credential3003Authorize Session
github_credential3003Authorize Session
token_exchange3003Authorize Session
oauth_token_issued3003Authorize Session
oauth_token_revoked3003Authorize Session
scim_operation3004Entity Management
oauth_client_registered3004Entity Management
oauth_client_updated3004Entity Management
oauth_client_deleted3004Entity Management
oauth_secret_added3004Entity Management
oauth_secret_revoked3004Entity Management
policy_denied3002Authentication
admin_policy_toggle3004Entity Management
admin_policy_create3004Entity Management
admin_policy_update3004Entity Management
admin_policy_delete3004Entity Management
admin_create_scim_token3004Entity Management
admin_delete_scim_token3004Entity Management
admin_revoke_scim_token3004Entity Management
org_domain_added3004Entity Management
org_domain_verified3004Entity Management
org_domain_removed3004Entity Management
org_domain_expired3004Entity Management
org_domain_unverified3004Entity Management
org_subdomain_claimed3004Entity Management
org_subdomain_released3004Entity Management
org_issuer_key_rotated3004Entity Management
org_issuer_key_revoked3004Entity Management
org_issuer_key_emergency_rotation3004Entity 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.

EndpointChecksSuccessFailure
GET /healthNothing — the process is running200, body ok (plain text, not JSON)Only fails if the process is hung or dead
GET /health/readyDatabase 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. /health is a liveness probe: it tells you whether to restart the process. /health/ready is a readiness probe: it tells you whether to send traffic. Pointing a readiness probe or a load balancer target group at /health means an instance whose database has failed keeps reporting healthy and keeps receiving requests.

Other unauthenticated endpoints useful for synthetic checks:

EndpointConfirms
/.well-known/openid-configurationThe OIDC provider is serving discovery
/.well-known/oauth-protected-resourceRFC 9728 protected-resource metadata
/oauth/jwksSigning keys are loaded and published
/v1/credentials/ssh/caThe 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

MetricTypeLabelsMeaning
http_requests_totalcountermethod, path, statusRequests served. See the label note below.
http_request_duration_secondshistogrammethod, pathRequest latency. Not labelled by status.
vouch_auth_events_totalcounterevent_typeAuthentication outcomes
vouch_credential_issuance_totalcountertypeCredentials 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:

  • path is 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 the request span.
  • method is one of the nine methods registered by RFC 9110. Any other token — HTTP permits any token as a method — is labelled OTHER.

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 HELP or TYPE descriptions 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.

FormatVOUCH_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

LevelRUST_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

ConditionSeverityWhy
/health non-200 or unreachableCriticalThe process is down
/health/ready returning 503CriticalThe database is unreachable; the instance can serve nothing
Sustained rise in fido2_login_failureWarningPossible credential stuffing, or a broken IdP integration
http_requests_total{status="429"} climbingWarningRate 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 keyWarningThe SSH CA is not loaded; SSH certificates cannot be issued
Database size growthWarningAdministrative audit events are never purged — see Audit Events
TLS certificate near expiryWarningVouch 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, cA is false;
  • if it carries the id-fido-gen-ce-aaguid extension, 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.

TierBurstSustainedApplies to
Authentication81 per 2s/oauth/token, /oauth/par, /oauth/fido2/challenge, /oauth/device, /oauth/register*, /v1/keys/register/*, /login/webauthn/*, /enroll/webauthn/*
Credential issuance151 per 2s/v1/credentials/ssh, /v1/credentials/aws/token, /v1/credentials/github/token
General201 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:

HeaderValue
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policysame-origin
Cross-Origin-Resource-Policysame-origin
X-DNS-Prefetch-Controloff
Cache-Controlno-cache (API routes additionally get no-store, must-revalidate)
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadonly 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_ORIGINS opts in specific origins with credentials enabled.
  • /oauth/authorize and /oauth/logout send 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

LimitValue
Global request timeout30 seconds (408 on expiry)
Global body limit256 KiB
Credential issuance8 KiB
SCIM, /oauth/authorize, SAML ACS64 KiB
Enroll and login WebAuthn32 KiB
GitHub webhook1 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:

  1. Registers /certification/complete-login and /certification/deny-login — a login bypass that mints a session for a synthetic user with no FIDO2 credential.
  2. Disables all rate limiting, globally.
  3. 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_SECRET is at least 32 random characters, or KMS HMAC is used instead
  • Durable VOUCH_OIDC_SIGNING_KEY and VOUCH_OIDC_RSA_SIGNING_KEY — not the ephemeral defaults
  • SSH CA key provisioned explicitly, so it cannot be silently auto-generated
  • VOUCH_ALLOWED_DOMAINS set, so enrollment is not open to any domain
  • Client IP preserved if anything fronts the server — VOUCH_TRUSTED_PROXIES for a proxy that terminates TLS, or client IP preservation on a TCP-passthrough target group
  • VOUCH_CERTIFICATION_TEST_TOKEN unset
  • VOUCH_METRICS_BEARER_TOKEN set to a strong random value if metrics are scraped
  • VOUCH_ALLOWED_AAGUIDS set 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

  1. Creationvouch login performs FIDO2 assertion with YubiKey touch + PIN
  2. Active — Access token stored in agent memory, valid for 8 hours (default)
  3. Usage — Credential helpers exchange the access token for service-specific credentials
  4. Expiry — Session expires automatically after the configured duration
  5. Revocationvouch logout explicitly 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:

KeyIf 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_SECRETAuthorization 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 /health and 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

ComponentCriticalityRecovery Impact
Document encryption KMS keyUnrecoverableOn an encrypted deployment, every stored document — including the entire audit history — becomes permanently unreadable. There is no regeneration path.
DatabaseCriticalLoss of user registrations, sessions, authenticator records
SSH CA private keyCriticalMust re-distribute new CA public key to all hosts
OIDC signing key (ES256)HighToken verification fails until new key distributed
OIDC RSA signing key (RS256)HighRS256 ID token verification fails until new key distributed
JWT secretHighAll sessions invalidated on change
TLS certificate & keyMediumService unavailable until replaced
Server configurationMediumCan 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_key block 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

  1. Deploy new server with the same configuration
  2. Restore database from backup
  3. Restore cryptographic keys (SSH CA, OIDC signing, JWT secret)
  4. Start the server — migrations run automatically if needed
  5. Verify: curl https://auth.example.com/health

Lost SSH CA Key

If the SSH CA key is lost and no backup exists:

  1. Generate a new SSH CA key
  2. Distribute the new public key to all SSH hosts
  3. Configure Vouch with the new key
  4. All users must run vouch login to get new certificates

Lost JWT Secret

If the JWT secret changes (lost or compromised):

  1. Set the new VOUCH_JWT_SECRET
  2. Restart the server
  3. All existing sessions are invalidated
  4. Users must run vouch login again

Database Corruption

  1. Stop the server
  2. Restore from backup
  3. Users who enrolled after the backup must re-enroll
  4. Start the server

Disaster Recovery Testing

Test the recovery procedures before you need them:

  1. Restore a database backup to a test environment
  2. Start a test server with production keys
  3. Verify enrollment, login, and credential flows
  4. Document any issues and update procedures

Software Updates

Update Procedure

Pre-Update

  1. Read the release notes for breaking changes
  2. Back up the database before upgrading
  3. 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:

  1. Stop the server
  2. Restore the database from the pre-upgrade backup
  3. Install the previous version
  4. 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

ChannelStabilityUse Case
latestStable releasesProduction
x.y.zPinned versionProduction (recommended)
mainDevelopment buildsTesting 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-samlVOUCH_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

  1. Check server health: curl -k https://auth.example.com/health
  2. Check DNS resolution: dig auth.example.com
  3. Check TLS: openssl s_client -connect auth.example.com:443
  4. 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”

  1. Verify the configured VOUCH_IDP_<SLUG>_ISSUER is correct and reachable: curl -s $VOUCH_IDP_<SLUG>_ISSUER/.well-known/openid-configuration | jq .issuer
  2. Verify the issuer URL uses HTTPS (HTTP is only allowed for localhost)
  3. Confirm the server can make outbound HTTPS requests (firewall, proxy)

“Issuer mismatch” during OIDC discovery

  • The issuer field in the discovery document must exactly match VOUCH_IDP_<SLUG>_ISSUER (trailing slashes matter)
  • Some providers require a trailing slash (e.g., Auth0: https://tenant.auth0.com/)
  • Entra /organizations/v2.0 is special-cased — its {tenantid} template issuer is accepted
  • Entra /common/v2.0 is rejected at startup; use /organizations/v2.0 or a single-tenant URL (see Microsoft Entra ID)

“Failed to fetch SAML IdP metadata”

  • Verify the configured VOUCH_IDP_<SLUG>_METADATA_URL is 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[].id must 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

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?

CompromisedBlast radiusSection
A user’s laptop or sessionThat 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 keyAn attacker can mint SSH certificates for any principal.SSH CA key
An OIDC signing keyAn attacker can mint access tokens and AWS federation assertions.Signing keys
The JWT secretAn attacker can forge authorization codes and CSRF state.JWT secret
A SCIM tokenAn attacker can create and delete users in your organization.SCIM token
The databaseRead access to audit history and token hashes. No usable private keys — they are not stored there.Database
The document encryption KMS keyOn an encrypted deployment, everything sealed by it.Document key

One user is compromised

Fastest containment, from the admin UI at /admin:

  1. 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.
  2. 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.
  3. Review /admin/audit filtered to that user for what was issued before containment: look for ssh_credential, aws_credential, github_credential, and token_exchange.
  4. 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.
  5. 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.

  1. Generate a new CA key on a trusted machine:

    ssh-keygen -t ed25519 -f ssh_ca_key.new -N "" -C "vouch-ca@example.com"
    
  2. Distribute the new public key to every host, alongside the old one initially:

    cat ssh_ca_key.new.pub >> /etc/ssh/vouch-ca.pub
    
  3. Switch the server to the new key (VOUCH_SSH_CA_KEY, VOUCH_SSH_CA_KEY_PATH, or VOUCH_SSH_CA_KMS_KEY_ID) and restart.

  4. 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 login to get certificates from the new CA.

  5. Audit for abuse. Certificates minted by an attacker with the stolen key never touched your server, so they are not in /admin/audit. Compare host sshd logs against the ssh_credential events 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).

  1. Generate a replacement — see Signing Keys.
  2. Update the configuration on every instance and restart. Mismatched keys across instances cause intermittent verification failures; see Running Multiple Instances.
  3. 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-hour Cache-Control, so federation may fail until it refetches.
  4. All previously issued tokens become invalid. Users run vouch login again.
  5. If the RS256 key was exposed, review CloudTrail for AssumeRoleWithWebIdentity calls you cannot attribute to an aws_credential audit event.

The JWT secret is compromised

VOUCH_JWT_SECRET signs authorization codes, WebAuthn challenge state, and CSRF tokens.

  1. Generate a new secret: openssl rand -base64 48
  2. Update every instance and restart.
  3. Every session is invalidated and all users must run vouch login again. 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.

  1. Revoke it at /admin/scim-tokens. Revocation takes effect immediately — tokens are checked against the database on every request.
  2. Issue a replacement and update your IdP’s SCIM configuration.
  3. Review /admin/audit for scim_operation events, 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.

  1. Rotate the JWT secret — session records are keyed against it.
  2. Rotate all SCIM tokens.
  3. Rotate OAuth client secrets for every registered application.
  4. Treat audit history as disclosed: it contains domain-masked emails, IP addresses, and geographic metadata.
  5. 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.

  1. Disable the KMS key and review CloudTrail for kms:Decrypt calls you cannot account for.
  2. Rotate every secret the documents contained: OAuth client secrets, SCIM tokens.
  3. There is no in-place document-key rotation mechanism. Provisioning a replacement requires vouch-server generate-document-key and 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:

  1. Treat it as an active authentication bypass. It mints sessions for a synthetic user with no hardware key, and it disables all rate limiting.
  2. Unset VOUCH_CERTIFICATION_TEST_TOKEN and restart immediately.
  3. Rotate the JWT secret to invalidate any session minted through the bypass.
  4. Review /admin/audit for login_success events with no authenticator_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-server and the vouch CLI 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

CapabilityWhere to read
Server install (RPM/DEB, containers, Helm)Installation, Deployment Methods
Configuration, TLS, database, SSH CAConfiguration Reference
Internal OIDC or SAML IdPIdentity Provider Overview, SAML 2.0
Enrollment (YubiKey + browser on internal network)Installation, YubiKey Provisioning
Key ceremony on a trusted workstationKey Ceremony
Day-two ops (time sync, updates, audit export scripts)Operations
Packages via sneakernetpackages.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_IDPS list with VOUCH_IDP_<SLUG>_TYPE=oidc plus the _ISSUER, _CLIENT_ID, and _CLIENT_SECRET variables 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=saml plus VOUCH_IDP_<SLUG>_METADATA_URL pointing 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

KeyTypeFormatRequiredPurpose
JWT SecretSymmetricUTF-8 (32+ chars)YesSigns internal state tokens (authorization codes, WebAuthn state, CSRF)
SSH CA KeyEd25519Base64-encoded OpenSSH PEMOptionalSign SSH certificates
OIDC Signing KeyP-256 ECDSABase64-encoded PKCS#8 PEMOptional*Sign OIDC ID tokens
TLS CertificateRSA/ECBase64-encoded PEMOptionalHTTPS encryption
TLS Private KeyRSA/ECBase64-encoded PEMOptionalHTTPS 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’s TrustedUserCAKeys entry stops matching. Provision the key before first start, or set VOUCH_SSH_CA_KEY with 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 enroll on a workstation that can open the device verification URL against the internal Vouch host; there is no separate headless-only enroll mode.

Each user:

  1. Opens a browser to https://auth.internal/enroll
  2. Authenticates via the configured identity provider
  3. 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

  1. File Permissions: Always use chmod 600 for private keys
  2. Never Commit Keys: Add *.pem, *_key, *.key to .gitignore
  3. Audit Key Access: Log all access to key material
  4. Backup Securely: Store encrypted backups in separate secure location
  5. Document Fingerprints: Record key fingerprints in secure documentation
  6. 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

  1. User opens https://auth.internal/enroll/start in a browser on their workstation
  2. User authenticates with the internal identity provider
  3. User inserts their YubiKey and completes the WebAuthn registration flow
  4. User sets a PIN on their YubiKey if one is not already configured (minimum 8 characters)
  5. 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:

  1. User reports lost key to administrator
  2. Administrator revokes the lost key’s credential via the web UI
  3. 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 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:

  1. Reference time from secure source (atomic clock, verified external)
  2. Set time on NTP server manually
  3. 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

  1. 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
  1. Verify signatures (connected environment)
rpm -K vouch-server-<new-version>-1.x86_64.rpm
rpm -K vouch-<new-version>-1.x86_64.rpm
  1. Transfer via approved media (sneakernet)

  2. Verify again (air-gapped environment)

rpm -K vouch-server-<new-version>-1.x86_64.rpm
sha256sum -c SHA256SUMS
  1. 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-server is 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

ComponentFrequencyMethodRetention
SQLite databaseDailyFile copy, encrypted90 days
SSH CA keysOn changeHSM backup or split custodyPermanent
ConfigurationOn changeGit (internal)Permanent
Audit logsContinuousAppend-only storagePer policy

Recovery Procedure

  1. Stop the service
systemctl stop vouch-server
  1. Restore database from backup
cp /data/vouch.db.backup.YYYYMMDD /data/vouch.db
chown vouch:vouch /data/vouch.db
  1. Re-sync time
# Verify NTP synchronization
timedatectl status
chronyc tracking  # or ntpq -p
  1. 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:

  1. Generate new CA from backup
  2. Re-provision all user credentials
  3. Redistribute new CA public key
  4. 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

RequirementNIST 800-53Implementation
Hardware authIA-2(1)FIDO2 with YubiKey
Credential lifetimeIA-5(1)8-hour certificates
Audit loggingAU-2, AU-3All credential issuance logged
Time syncAU-8GPS/NTP infrastructure
Key managementSC-12HSM 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

VariableRequiredDefaultDescription
VOUCH_RP_IDNolocalhostRelying 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_NAMENoVouchRelying Party display name shown in browser prompts and UI.
VOUCH_DATABASE_URLNosqlite:vouch.db?mode=rwcDatabase 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_SECRETConditional(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_URLNohttps://{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_NAMENo(none)Organization name for branding in the UI. Falls back to VOUCH_RP_NAME if not set.
VOUCH_ALLOWED_DOMAINSNo(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

VariableRequiredDefaultDescription
VOUCH_LISTEN_ADDRNo[::]:3000Address and port to listen on. Ignored when TLS is configured — the server then binds 443 and 80 unconditionally.
VOUCH_MTLS_PORTNo8443Port 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_PROXIESNo(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_CERTSNo(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.

VariableRequiredDefaultDescription
VOUCH_IDPSYes(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>_TYPEYes (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.

VariableRequiredDefaultDescription
VOUCH_IDP_<SLUG>_ISSUERYes(none)OIDC issuer URL (e.g., https://accounts.google.com). Must serve a valid OIDC discovery document.
VOUCH_IDP_<SLUG>_CLIENT_IDYes(none)OIDC client ID from the IdP.
VOUCH_IDP_<SLUG>_CLIENT_SECRETYes(none)OIDC client secret from the IdP.

SAML IdP (per slug)

VariableRequiredDefaultDescription
VOUCH_IDP_<SLUG>_METADATA_URLYes(none)URL to the SAML IdP metadata XML document. Fetched at server startup.
VOUCH_IDP_<SLUG>_SP_ENTITY_IDNo{VOUCH_BASE_URL}SP entity ID sent in authentication requests. Defaults to the server’s base URL.
VOUCH_IDP_<SLUG>_EMAIL_ATTRIBUTENo(auto-detect)SAML attribute name containing the user’s email address.
VOUCH_IDP_<SLUG>_DOMAIN_ATTRIBUTENo(none)SAML attribute name containing the user’s domain (for domain restriction).

Session

VariableRequiredDefaultDescription
VOUCH_SESSION_HOURSNo8Session duration in hours. After this time, the user must re-authenticate.
VOUCH_DEVICE_CODE_EXPIRESNo600Device code expiration in seconds. How long a device code remains valid during enrollment.
VOUCH_DEVICE_POLL_INTERVALNo5Device code polling interval in seconds. How frequently the CLI polls for device code completion.
VOUCH_SESSION_CACHE_MAX_CAPACITYNo10000Maximum entries in the in-memory session lookup cache.
VOUCH_SESSION_CACHE_TTL_SECSNo30How 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

VariableRequiredDefaultDescription
VOUCH_SSH_CA_KEYNo(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_PATHNo./ssh_ca_keyPath 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_PATH file causes the server to generate a new CA key, starting on a fresh or unmounted volume silently rotates your SSH CA. Every host’s TrustedUserCAKeys entry then stops matching and users cannot log in with newly issued certificates. Either provision the key file before first start, or supply the key through VOUCH_SSH_CA_KEY / VOUCH_SSH_CA_KMS_KEY_ID, which never auto-generate.

OIDC Signing

VariableRequiredDefaultDescription
VOUCH_OIDC_SIGNING_KEYNo(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_KEYNo(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

VariableRequiredDefaultDescription
VOUCH_SSH_CA_KMS_KEY_IDNo(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_IDNo(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_IDNo(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_IDNo(none)AWS KMS key ID for HMAC state token signing. When set, VOUCH_JWT_SECRET is not required.

DPoP

VariableRequiredDefaultDescription
VOUCH_DPOP_MAX_AGENo300Maximum age of DPoP proofs in seconds. Proofs older than this are rejected.

Cleanup & Retention

VariableRequiredDefaultDescription
VOUCH_CLEANUP_INTERVALNo15Background cleanup task interval in minutes. Set to 0 to disable automatic cleanup.
VOUCH_AUTH_EVENTS_RETENTION_DAYSNo90Retention period for authentication events in days. Events older than this are purged during cleanup.
VOUCH_OAUTH_EVENTS_RETENTION_DAYSNo90Retention 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

VariableRequiredDefaultDescription
VOUCH_CORS_ORIGINSNo(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.

VariableRequiredDefaultDescription
VOUCH_GITHUB_APP_IDNo(none)GitHub App ID (numeric, assigned when creating the app on github.com).
VOUCH_GITHUB_APP_NAMENo(none)GitHub App name (the slug from github.com/apps/{name}).
VOUCH_GITHUB_APP_KEYNo(none)GitHub App private key (PEM format, RSA). Can use literal \n for newlines.
VOUCH_GITHUB_WEBHOOK_SECRETNo(none)GitHub webhook secret for verifying webhook signatures (HMAC-SHA256).
VOUCH_GITHUB_APP_CLIENT_IDNo(none)GitHub App Client ID for OAuth user authentication. Found in GitHub App settings (different from the numeric App ID).
VOUCH_GITHUB_APP_CLIENT_SECRETNo(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.

VariableRequiredDefaultDescription
VOUCH_TLS_CERTNo(none)TLS certificate (base64-encoded PEM).
VOUCH_TLS_KEYNo(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.

VariableRequiredDefaultDescription
VOUCH_S3_CONFIG_BUCKETNo(none)S3 bucket name for configuration file. If set, config is loaded from S3.
VOUCH_S3_CONFIG_KEYNoconfig/vouch-server.jsonS3 object key for configuration file.
VOUCH_S3_CONFIG_REGIONNo(auto)AWS region for S3 access. Uses the default credential chain region if not set.
VOUCH_S3_CONFIG_POLL_INTERVALNo60S3 config polling interval in seconds. How frequently the server checks for configuration changes.

JWT Assertion

VariableRequiredDefaultDescription
VOUCH_JWT_ASSERTION_MAX_LIFETIMENo300Maximum 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.

VariableRequiredDefaultDescription
VOUCH_RESOURCE_NAMENoVouchHuman-readable name of this protected resource.
VOUCH_RESOURCE_DOCUMENTATIONNohttps://vouch.sh/docs/URL of developer documentation for this protected resource.
VOUCH_RESOURCE_POLICY_URINohttps://vouch.sh/privacy/URL of the resource’s data-use policy.
VOUCH_RESOURCE_TOS_URINohttps://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 /privacy and /terms UI routes, which are fixed redirects to vouch.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.

VariableRequiredDefaultDescription
VOUCH_SECURITY_CONTACTNosecurity@vouch.shContact 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.

VariableRequiredDefaultDescription
VOUCH_CLI_DOWNLOAD_MACOSNo(none)CLI download URL for macOS, displayed in the server UI.
VOUCH_CLI_DOWNLOAD_LINUXNo(none)CLI download URL for Linux, displayed in the server UI.
VOUCH_CLI_DOWNLOAD_WINDOWSNo(none)CLI download URL for Windows, displayed in the server UI.

Database Tuning

VariableRequiredDefaultDescription
VOUCH_DB_MAX_CONNECTIONSNo25Maximum size of the connection pool. Multiply by your instance count when sizing PostgreSQL’s max_connections.
VOUCH_DB_MIN_CONNECTIONSNo2Minimum idle connections kept open.
VOUCH_DB_IDLE_TIMEOUT_SECSNo300How long an idle connection is kept before being closed.
VOUCH_DB_ACQUIRE_TIMEOUT_SECSNo5How long a request waits for a free connection before failing.
DSQL_USERNoadminNot VOUCH_-prefixed. Database username for Aurora DSQL when the connection URL carries none.

Authenticator Policy

VariableRequiredDefaultDescription
VOUCH_ALLOWED_AAGUIDSNo(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

VariableRequiredDefaultDescription
VOUCH_LOG_FORMATNotextLog output format: text or json. Any other value is a fatal startup error.
VOUCH_METRICS_BEARER_TOKENNo(none)Bearer token protecting GET /metrics. When unset, the metrics endpoint is not registered at all.
RUST_LOGNoinfoNot VOUCH_-prefixed. Standard EnvFilter directive, e.g. info,vouch_server=debug.
OTEL_EXPORTER_OTLP_ENDPOINTNo(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_NAMENovouch-serverNot VOUCH_-prefixed. Service name attached to exported spans.

See Monitoring and Metrics.

AWS Environment

These are read by the AWS SDK or by Vouch’s AWS-specific resolution logic. None are VOUCH_-prefixed.

VariableUsed for
AWS_REGION / AWS_DEFAULT_REGIONRegion for KMS and S3, and for resolving dsql_endpoints
AWS_AZAvailability zone, checked first when resolving dsql_endpoints
AWS_PARTITIONPartition segment (aws, aws-us-gov) when building cross-account KMS ARNs
AWS_USE_FIPS_ENDPOINTWhether 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 VouchConfigParameter EC2 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=VALUE lines, one per line, with # comments and blank lines allowed — the same format systemd’s EnvironmentFile= accepts. No export prefix, 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-server CLI flag (every VOUCH_* variable in this reference, plus AWS_REGION/AWS_AZ/AWS_PARTITION/ AWS_USE_FIPS_ENDPOINT) are read from the parameter. Anything else in the blob (for example a stray RUST_LOG) is ignored, since it was never real process environment to begin with. In particular, use AWS_REGION — not AWS_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_FAILED line naming the parameter and region. The unit’s Restart=always retries 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_BUCKET variable or the --s3-config-bucket flag), the IMDS probe is skipped entirely, so non-EC2 and fully env-configured deployments pay nothing.

Test Mode

VariableRequiredDefaultDescription
VOUCH_CERTIFICATION_TEST_TOKENNo(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.

ConditionMessage
VOUCH_IDPS empty or unsetNo upstream IdP configured. Set VOUCH_IDPS=<slug>[,<slug>...]
A slug fails [a-z0-9-]{1,32}, or leads/trails with a hyphenInvalid provider slug
Two IdPs share a slugDuplicate 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 samlInvalid type
An IdP’s OIDC discovery or SAML metadata fetch failsFailed to configure IdP '<id>'
Only one of VOUCH_TLS_CERT / VOUCH_TLS_KEY is setPartial TLS configuration: set both ... or neither.
VOUCH_JWT_SECRET under 32 characters, and no KMS HMAC keyVOUCH_JWT_SECRET must be at least 32 characters
VOUCH_JWT_SECRET is a single repeated charactermust not consist of a single repeated character
Either retention variable is negativeNegative retention rejected
VOUCH_CORS_ORIGINS contains *Wildcard is invalid with credentialed cookie sessions
VOUCH_ALLOWED_AAGUIDS has a non-UUID entryInvalid VOUCH_ALLOWED_AAGUIDS
VOUCH_LOG_FORMAT is not text or jsonInvalid VOUCH_LOG_FORMAT
VOUCH_TRUSTED_PROXIES has a malformed CIDRInvalid CIDR in VOUCH_TRUSTED_PROXIES
VOUCH_EXTRA_CA_CERTS file is unreadableRead failure
VOUCH_DATABASE_URL scheme is not sqlite:/postgres:/postgresql:Unsupported scheme
A KMS key ID is set but the KMS client cannot be builtNames the key
S3 configuration is enabled but the object cannot be fetched or parsedFailed to fetch S3 configuration
Issuer subdomains are claimed but document encryption is not configuredissuer subdomains are claimed but document encryption is not configured
The mTLS listener cannot bindFailed 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

FieldTypeDescription
versionintegerSchema version. Must be 1.
listen_addrstringAddress and port to listen on (e.g., 0.0.0.0:443).
rp_idstringRelying Party ID (domain). Used as the WebAuthn RP ID.
rp_namestringRelying Party display name for browser prompts and UI.
base_urlstringExternal base URL for the server.
database_urlstringDatabase connection URL (sqlite:, postgres:, or Aurora DSQL).
dsql_endpointsobjectRegional DSQL endpoints. Maps AWS region to full connection string.
jwt_secretstringJWT signing secret (minimum 32 characters). Not required if jwt_hmac_kms_key_id is set.
session_hoursintegerSession duration in hours.
org_namestringOrganization display name for branding in the UI.
tls.certstringTLS certificate (base64-encoded PEM).
tls.keystringTLS private key (base64-encoded PEM).
idps[]array of objectsConfigured identity providers (OIDC + SAML). Order controls login-page button order. Each entry has id, type ("oidc" or "saml"), and type-specific fields.
idps[].idstringOperator-chosen slug ([a-z0-9-]{1,32}, no leading/trailing hyphen, unique). Used in the state table, callback routing, and audit logs.
idps[].typestring"oidc" or "saml".
idps[].issuer (OIDC)stringOIDC issuer URL. The server auto-discovers endpoints.
idps[].client_id (OIDC)stringOIDC client ID from the IdP.
idps[].client_secret (OIDC)stringOIDC client secret from the IdP.
idps[].metadata_url (SAML)stringURL to the SAML IdP metadata XML document.
idps[].sp_entity_id (SAML)stringSP entity ID (defaults to base_url).
idps[].email_attribute (SAML)stringSAML attribute name for email extraction.
idps[].domain_attribute (SAML)stringSAML attribute name for domain extraction.
allowed_domainsarray of stringsAllowed email domains for enrollment.
ssh_ca_keystringSSH CA private key (base64-encoded PEM, Ed25519).
ssh_ca_kms_key_idstringAWS KMS key ID for SSH CA signing (Ed25519). Overrides ssh_ca_key.
oidc_signing_keystringOIDC signing key (base64-encoded PEM, P-256 ECDSA).
oidc_signing_kms_key_idstringAWS KMS key ID for OIDC token signing (P-256). Overrides oidc_signing_key.
oidc_rsa_signing_keystringOIDC RSA signing key (base64-encoded PEM, RSA-3072). Signs ID tokens with RS256.
oidc_rsa_signing_kms_key_idstringAWS KMS key ID for OIDC RSA signing (RSA-3072). Overrides oidc_rsa_signing_key.
jwt_hmac_kms_key_idstringAWS KMS key ID for HMAC state token signing. Overrides jwt_secret.
document_keyobjectDocument encryption key. Contains kms_key_id, encrypted_private_key, and optional algorithm (default "p384", currently the only value).
dpop.max_age_secondsintegerMaximum age of DPoP proofs in seconds.
cors_originsarray of stringsCORS allowed origins.
github.app_idintegerGitHub App ID.
github.app_namestringGitHub App name (slug from github.com/apps/{name}).
github.app_keystringGitHub App private key (PEM RSA).
github.webhook_secretstringGitHub webhook secret for signature verification.
github.client_idstringGitHub App OAuth client ID.
github.client_secretstringGitHub App OAuth client secret.
cleanup_interval_minutesintegerBackground cleanup task interval in minutes.
auth_events_retention_daysintegerRetention period for authentication events in days.
oauth_events_retention_daysintegerRetention period for OAuth usage and credential-issuance (aws_credential, github_credential, ssh_credential, token_exchange) events in days.
resource_namestringHuman-readable name of this protected resource (RFC 9728). Defaults to "Vouch".
resource_documentationstringURL of developer documentation for this protected resource (RFC 9728). Defaults to "https://vouch.sh/docs/".
resource_policy_uristringURL of the resource’s data-use policy (RFC 9728). Defaults to "https://vouch.sh/privacy/".
resource_tos_uristringURL of the resource’s terms of service (RFC 9728). Defaults to "https://vouch.sh/terms/".
cli_download_macosstringCLI download URL for macOS, displayed in the server UI.
cli_download_linuxstringCLI download URL for Linux, displayed in the server UI.
cli_download_windowsstringCLI download URL for Windows, displayed in the server UI.
device_code_expires_secondsintegerDevice code expiration in seconds.
device_poll_interval_secondsintegerDevice code polling interval in seconds.

Reserved Keys

KeyOwnerDescription
_acmeExternal certificate renewal processACME (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

PortProtocolPurposeConfigurable
443HTTPSMain listenerNo — fixed whenever TLS is configured
80HTTP308 redirect to HTTPS, plus /healthNo
8443HTTPS + mTLSClient-certificate listener for RFC 8705 certificate-bound tokensPort only, via VOUCH_MTLS_PORT
3000HTTPDefault listener when TLS is not configuredYes, 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_SERVICE on 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

AuthWhat it means
NonePublic, unauthenticated
Bearer/DPoPA Vouch access token, in Authorization: Bearer or Authorization: DPoP
SignedBearer/DPoP plus an RFC 9421 HTTP message signature
SessionBrowser cookie session
AdminSession or Bearer, and the user must be an active org administrator
SCIM tokenA vouch_scim_… bearer token
Metrics tokenThe VOUCH_METRICS_BEARER_TOKEN value
HMACGitHub webhook signature

Operations

EndpointMethodAuthNotes
/healthGETNoneLiveness. Returns ok as plain text. Also served on port 80
/health/readyGETNoneReadiness. Checks the database; 503 when unreachable
/metricsGETMetrics tokenOnly registered when VOUCH_METRICS_BEARER_TOKEN is set

Discovery and metadata

EndpointMethodAuth
/.well-known/openid-configurationGETNone
/.well-known/oauth-authorization-serverGETNone
/.well-known/oauth-protected-resourceGETNone
/.well-known/security.txtGETNone
/oauth/jwksGETNone
/saml/metadataGETNone

OAuth and authentication

EndpointMethodAuthRate-limit tier
/oauth/tokenPOSTClient authAuthentication
/oauth/parPOSTClient authAuthentication
/oauth/fido2/challengePOSTNoneAuthentication
/oauth/devicePOSTClient auth (RFC 8628 §3.1); enrolling requires CLI 2026.9.4 or laterAuthentication
/oauth/registerPOSTNone (RFC 7591)Authentication
/oauth/register/{client_id}GET/PUT/DELETERegistration access tokenAuthentication
/oauth/authorizeGETSessionGeneral
/oauth/introspectPOSTClient authGeneral
/oauth/revokePOSTClient authGeneral
/oauth/userinfoGETBearer/DPoPNot limited
/oauth/callbackGETNone (IdP redirect)Not limited
/saml/acsPOSTNone (IdP assertion)Not limited

Credentials and keys

EndpointMethodAuthRate-limit tier
/v1/credentials/sshPOSTSignedCredential
/v1/credentials/aws/tokenGETSignedCredential
/v1/credentials/github/tokenPOSTSignedCredential
/v1/credentials/ssh/caGETNoneGeneral
/v1/credentials/ssh/krlGETNoneGeneral
/v1/credentials/ssh/krl/{serial}GETNoneGeneral
/v1/credentials/github/statusGETNoneGeneral
/v1/keysGETSignedGeneral
/v1/keys/{id}PATCH/DELETESignedGeneral
/v1/keys/register/start · /completePOSTSignedAuthentication
/v1/auth/statusGETNoneNot limited

The public read endpoints are unauthenticated by design: SSH hosts fetch the CA public key and revocation list without holding credentials.

Administration

EndpointMethodAuthRate-limit tier
/admin and /admin/*GET/POSTAdminGeneral
/api/v1/org/scim-tokensGET/POSTAdminGeneral
/api/v1/org/scim-tokens/{id}DELETEAdminGeneral
/api/v1/org/policies/validatePOSTAdminGeneral
/scim/v2/*GET/POST/PUT/PATCH/DELETESCIM tokenGeneral
/api/v1/applications*variousBearer/DPoPGeneral
/api/webhooks/githubPOSTHMACGeneral

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

ScopeLimit
Global timeout30 seconds (408 on expiry)
Global body256 KiB
Credential issuance8 KiB
SCIM, /oauth/authorize, SAML ACS64 KiB
Enroll and login WebAuthn32 KiB
GitHub webhook1 MiB

Outbound connections

The server itself makes outbound HTTPS calls; egress rules must allow them:

DestinationWhen
Your upstream IdP (discovery, JWKS, token)Always — at startup and during enrollment
Your SAML IdP metadata URLAt startup, if a SAML IdP is configured
AWS KMS, S3, STSWhen KMS keys, S3 configuration, or the AWS integration are used
api.github.comWhen the GitHub App integration is used
DNS resolversDomain-ownership TXT verification
An OAuth client’s jwks_uriAt 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.