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_keyorvault_transitfor multi-node.
Choose your backends
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:
Wired automatically when signing.key_store: postgres_key. SQLite deployments and keyfile-driver deployments rely on SIGHUP instead.
In-memory caches
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:
- New key pair generated + persisted (keyfile writes to
/var/lib/authserver/keys/; Vault Transit andpostgres_keywrite to their backends). - The new key becomes
current; previously current is demoted toprevious. Keys older thanpreviousare removed from the JWKS. /.well-known/jwks.jsonincludes bothcurrentandprevious— verifiers that cache the JWKS pick up the new key on their next refresh (SDKs default to 1h).- 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
Related
- Operate overview — mode picker
- Docker Compose — same setup with containers
- Kubernetes (Helm) — same setup at HA scale
- Vault Transit — HSM-grade signing
- Backup, upgrade, purge — data lifecycle
- Configuration reference — every env var and YAML key