Standalone binary

TL;DR — Download the release binary, create a dedicated user, drop a YAML config, wire up systemd with the hardening flags below, front with Caddy or nginx for TLS, and you have a production-grade AuthPlane on a single Linux host. No Docker required. SQLite by default; drop in Postgres when you’re ready. Keyfile signing works out of the box; postgres_key or vault_transit for multi-node.

Choose your backends

DecisionDevelopmentProduction (single node)Production (multi-node)
StorageSQLite (default)PostgreSQLPostgreSQL (required)
Signing keyskeyfile (default)keyfile or postgres_keypostgres_key or vault_transit
Data encryptionaes_masteraes_masteraes_master or vault_transit_encrypt

PostgreSQL is required for multi-node because the postgres_key signing driver uses LISTEN/NOTIFY on the signing_key_change channel for zero-downtime signing-key rotation across instances.

Download

curl -L https://github.com/authplane/authserver/releases/latest/download/authserver-linux-amd64 \
    -o /usr/local/bin/authserver
chmod +x /usr/local/bin/authserver
authserver version

Create user and directories

# Dedicated system user with no home + no shell
useradd --system --no-create-home --shell /usr/sbin/nologin authserver

# Data + config dirs
mkdir -p /var/lib/authserver/keys
mkdir -p /etc/authserver

# Ownership
chown -R authserver:authserver /var/lib/authserver

Configuration

Create /etc/authserver/config.yaml:

server:
  issuer: https://auth.example.com
  address: ":9000"

storage:
  driver: sqlite
  sqlite:
    path: /var/lib/authserver/authserver.db
    wal: true

signing:
  algorithm: ES256
  key_store: keyfile
  key_path: /var/lib/authserver/keys

session:
  secret: "generate-a-32-byte-random-string"
  secure: true
  same_site: lax

admin:
  enabled: true
  address: "127.0.0.1:9001"      # loopback only
  api_key: "generate-a-secure-api-key"

resources:
  - slug: my-mcp-server
    backend_kind: mint
    name: My MCP Server
    uri: http://localhost:3000/mcp
    scopes:
      - name: tools/echo
        description: Echo a message back to the caller

Runtime management — the YAML resources: block is optional seed data; entries are inserted on startup when no row with the same slug exists. Manage resources at runtime via the Admin API (/admin/resources) or CLI (authserver admin resource create).

Generate secrets and lock down the config file (contains sensitive values):

openssl rand -base64 32     # session secret
openssl rand -hex 24        # admin API key

chmod 600 /etc/authserver/config.yaml
chown authserver:authserver /etc/authserver/config.yaml

Run migrations

sudo -u authserver authserver migrate --config /etc/authserver/config.yaml

Idempotent — safe to re-run on an already-migrated DB. Note the --config flag: authserver migrate accepts no positional path argument — passing one silently uses the default DB in the working directory instead of your configured one.

Create the first admin user

sudo -u authserver authserver admin user create \
    --config /etc/authserver/config.yaml \
    --email admin@example.com \
    --password changeme \
    --name Admin \
    --role admin

Change the password immediately after first login.

systemd unit

Create /etc/systemd/system/authserver.service:

[Unit]
Description=AuthPlane MCP Authorization Server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=authserver
Group=authserver
ExecStart=/usr/local/bin/authserver serve --config /etc/authserver/config.yaml
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/authserver
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target

Enable and start:

systemctl daemon-reload
systemctl enable authserver
systemctl start authserver

Watch logs:

systemctl status authserver
journalctl -u authserver -f

Reverse proxy

The public OAuth server listens on :9000; front it with TLS.

Caddy

auth.example.com {
    reverse_proxy localhost:9000
}

Caddy handles Let’s Encrypt automatically. Zero config beyond this.

nginx

server {
    listen 443 ssl;
    server_name auth.example.com;

    ssl_certificate /etc/letsencrypt/live/auth.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/auth.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:9000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The admin API stays bound to 127.0.0.1:9001 — accessible only from the local machine. Reach the Admin UI via SSH tunnel:

ssh -L 9001:127.0.0.1:9001 user@host
# then open http://localhost:9001/admin/ui/

PostgreSQL (optional)

For deployments that want Postgres instead of SQLite:

apt install postgresql
sudo -u postgres createuser authserver
sudo -u postgres createdb -O authserver authserver

Update /etc/authserver/config.yaml:

storage:
  driver: postgres
  postgres:
    dsn: "postgres://authserver@localhost:5432/authserver?sslmode=disable"

Re-run migrations, restart:

sudo -u authserver authserver migrate --config /etc/authserver/config.yaml
systemctl restart authserver

Operational features

SIGHUP — reload signing keys without restart

Hot-reload after rotating keys on disk or via Vault Transit:

kill -HUP $(pidof authserver)
# Or with systemd
systemctl kill -s HUP authserver

Logs signing keys reloaded on success. No connections dropped.

PostgreSQL LISTEN/NOTIFY (multi-instance)

The unified resource model reads resources, broker providers, and per-resource policy directly from the database on every request — no in-memory registry that needs cross-instance invalidation. The only channel that requires LISTEN/NOTIFY today is signing-key rotation:

ChannelDriverTriggered byEffect
signing_key_changesigning.key_store: postgres_keysigning_keys row insert/update (POST /admin/keys/rotate, SIGHUP-triggered reloads)Each instance reloads the JWKS cache so the new kid is published immediately

Wired automatically when signing.key_store: postgres_key. SQLite deployments and keyfile-driver deployments rely on SIGHUP instead.

In-memory caches

CacheTTLInvalidated by
JWKS document (/.well-known/jwks.json)TTL-less; cleared on demandsigning_key_change NOTIFY (multi-instance), SIGHUP, or POST /admin/keys/rotate
IdP JWKS keys (XAA)xaa.jwks_cache_ttl (default 1h)POST /admin/idps/{id}/refresh-keys or TTL expiry

Resource registry, broker-provider, and per-resource policy reads are not cached — every request hits the DB via an indexed lookup. If you’re debugging stale-config symptoms, the DB is the single source of truth.

Tuning xaa.jwks_cache_ttl — the default 1h balances freshness against IdP load. Lower it (e.g. 5m) when an upstream IdP rotates JWKS aggressively or when you want a shorter blast radius after key compromise; raise it when the IdP rate-limits JWKS fetches. YAML-only field (no env-var override). Next assertion verification refreshes the cache after lowering — no restart needed.

Signing key rotation

Zero-downtime hot operation. Previous key stays in the JWKS document so outstanding tokens remain verifiable; new tokens are signed with the new key from the first /oauth/token call after rotation.

# Via Admin API
curl -X POST http://localhost:9001/admin/keys/rotate \
    -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY"

# Or via CLI
authserver admin key rotate

When to rotate:

  • Calendar cadence (e.g. every 90 days) as part of key-hygiene policy.
  • After a suspected key-material exposure — backup loss, stolen disk, operator departure with past /var/lib/authserver/keys/ access.
  • During a kid-format migration (ES256 ↔ RS256) — rotate to switch algorithms gradually; old tokens keep verifying until they expire.

What happens:

  1. New key pair generated + persisted (keyfile writes to /var/lib/authserver/keys/; Vault Transit and postgres_key write to their backends).
  2. The new key becomes current; previously current is demoted to previous. Keys older than previous are removed from the JWKS.
  3. /.well-known/jwks.json includes both current and previous — verifiers that cache the JWKS pick up the new key on their next refresh (SDKs default to 1h).
  4. All tokens signed after rotation carry the new kid. In-flight tokens signed by the previous key continue to verify until they expire.

On multi-instance deployments the new key propagates via PostgreSQL LISTEN/NOTIFY (ms) or the keyfile’s filesystem watcher on the next scheduled refresh — no manual SIGHUP required with postgres_key or vault_transit. Single-instance keyfile can use SIGHUP for immediate reload.

Full rotation policy: Security: Key management.

CORS warning at boot

AuthPlane logs a startup WARN when server.allowed_origins is empty (or AUTHPLANE_SERVER_ALLOWED_ORIGINS is unset):

WARN  CORS is disabled (AUTHPLANE_SERVER_ALLOWED_ORIGINS is empty);
      browser-based MCP clients (MCP Inspector, Claude Desktop, etc.)
      will silently fail on /oauth/token, /oauth/introspect, and
      /oauth/revoke due to CORS preflight rejections.
      For local dev set AUTHPLANE_SERVER_ALLOWED_ORIGINS=*;
      for production set an explicit origin allowlist.

Intentionally a WARN, not fatal — server-to-server-only deployments may legitimately leave this empty. If you see it in a deployment that needs browser clients, set server.allowed_origins (or AUTHPLANE_SERVER_ALLOWED_ORIGINS) to your origin allowlist (or * for local dev).

Scheduled authserver purge

serve does not run purge goroutines — schedule externally. Recipes in Backup, upgrade, purge.

The rate limiter cache is cleaned in-process every 5 minutes; it’s not persisted and doesn’t need scheduled purging.

Firewall

# Public — via reverse proxy
ufw allow 443/tcp

# Block direct access to authserver ports from outside
ufw deny 9000/tcp
ufw deny 9001/tcp