Skip to content

Server configuration examples

Complete configurations rather than isolated keys. Each one is a file you could put at /etc/ssoossh/ssoosshd.yaml and start from. Every key used here is documented in the configuration reference.

A CA key, a public URL, and an OIDC client. Everything else has a working default.

# The CA private key ssoosshd signs certificates with. Inline PEM, not a
# file path. Generate one with: ssh-keygen -t ed25519 -f ca -N ""
ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
-----END OPENSSH PRIVATE KEY-----
http:
# The scheme and host browsers actually reach this deployment at.
# Required for the OIDC redirect URI and the CSRF origin check.
public_url: "https://ssh.example.com"
authentication:
client_id: "..."
client_secret: "..."
provider_url: "https://idp.example.com"

The one redirect URI your identity provider needs is <http.public_url>/auth/callback.

Plain HTTP only works for loopback development.

ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
-----END OPENSSH PRIVATE KEY-----
http:
public_url: "https://ssh.example.com"
address: "0.0.0.0"
port: 443
tls:
certificate_file: /etc/ssoossh/tls/server.crt
private_key_file: /etc/ssoossh/tls/server.key
authentication:
client_id: "..."
client_secret: "..."
provider_url: "https://idp.example.com"

Paths only. PEM pasted inline is not accepted, because it could not then be rotated without rewriting this file.

When something else terminates TLS, ssoosshd needs to know which hop to believe about the client’s address. trusted_proxies is empty by default, which means no proxy is trusted and X-Forwarded-For is ignored entirely.

ssh_key: |
-----BEGIN OPENSSH PRIVATE KEY-----
-----END OPENSSH PRIVATE KEY-----
http:
# What browsers reach, not what ssoosshd binds. The proxy terminates TLS,
# so this stays https even though the listener below is plain.
public_url: "https://ssh.example.com"
address: "127.0.0.1"
port: 8080
# The proxy's own address, in CIDR form. Only these hops are believed
# about X-Forwarded-For / X-Forwarded-Proto.
trusted_proxies:
- "127.0.0.1/32"
# The session cookie is marked Secure automatically because public_url is
# https. There is no separate switch: the scheme stated here is the one
# the cookie follows.
authentication:
client_id: "..."
client_secret: "..."
provider_url: "https://idp.example.com"

SQLite is the default and is a file path. PostgreSQL takes a standard URL.

db:
provider: postgres
connection_string: "postgres://user:pass@db.example.com/ssoossh"
max_open_conns: 25
max_idle_conns: 10

For SQLite under systemd, put the file in the unit’s StateDirectory:

db:
connection_string: /var/lib/ssoossh/ssoossh.db

More than one ssoosshd process sharing a database. This requires PostgreSQL, NATS with mTLS, and – enforced at startup – an explicit cookie key, so sessions survive a request landing on a different instance.

multi_instance: true
http:
public_url: "https://ssh.example.com"
# The same value on every instance. Without it, the first instance to start
# generates a key and stores it in the database for the rest to share; an
# explicit key is still the clearer choice, since it does not depend on
# which instance won that race.
cookie_key: "your-secret-key-here-32-bytes-minimum"
db:
provider: postgres
connection_string: "postgres://user:pass@db.example.com/ssoossh"
pubsub:
backend: nats
nats:
url: "nats://nats.example.com:4222"
cert_file: "/path/to/client-cert.pem"
key_file: "/path/to/client-key.pem"
ca_file: "/path/to/ca-cert.pem"

The startup modes that go with it:

Command Runs Requires
ssoosshd serve HTTP, listener, and in-process signer nothing extra
ssoosshd serve api HTTP and listener; publishes signing jobs pubsub.backend: nats
ssoosshd sign the signer only; holds the CA key, no database or HTTP pubsub.backend: nats and a CA key source (ssh_key, ssh_key_file, ssh_key_agent or hsm)

Both split modes fail at startup if the in-process pubsub backend is configured.

All three group names are optional, and each is an OIDC group name. Empty disables or narrows the role rather than opening it up – authorization fails closed.

admin:
# Full administrative access: diagnostics, claims echo, the directory
# sync and probe, plus everything SOC and auditor can do.
require_group: "ssh-admins"
# Account writes: disabling and re-enabling users, expiring enrollments.
# Admins hold this regardless, so leaving it empty narrows SOC actions to
# admins.
soc_group: "security-ops"
# Read-only: effective configuration, cross-user certificate history.
auditor_group: "ssh-auditors"
# Shown on the account-disabled page. The contact it names is
# branding.support_email, the same address the login page shows.
disabled_message: "Contact the security team with your ticket number."

Because authorization is evaluated from the session identity, the session lifetime is the revocation window: removing someone from an admin group in the identity provider takes effect at their next login.

Off by default: mail.enabled: true turns it on, and then a sender and a relay are required. The relay is smtp.host, which defaults to localhost.

mail:
enabled: true
from: "ssoossh@example.com"
reply_to: "ssh-admins@example.com"
smtp:
host: "smtp.example.com"
username: "ssoossh@example.com"
# Read from a file so the secret is not in this config.
password_file: "/run/secrets/ssoossh-smtp-password"
helo: "ssoossh.example.com"

Every mail key is on the mail reference pages.

The key ID is what shows up in an audit log on the target host, so it is worth making it identify the person.

authentication:
fields:
# Capture extra claims at login, under names your templates can use.
extra:
dept: "https://idp.example.com/department"
accounts: altAccounts
cert_options:
user:
key_id_template: '{{.Username}}-{{.Extra.dept}}'
pam:
# PAM deliberately never falls back to the user template, so a sudo and
# an SSH login by the same person stay distinguishable.
key_id_template: 'sudo:{{.Username}}'

A field with no value at issuance renders as MISSING rather than collapsing to an empty string, so a key ID shows an auditable gap (alice-MISSING) instead of quietly losing a segment. A typo in a standard field name, or malformed template syntax, fails at startup instead – every configured template is parsed and test-executed once at boot.

# Steers the CA key, client-submitted public keys, and the TLS profile toward
# FIPS 140-3 approved algorithms. A non-approved algorithm becomes a hard
# error rather than a warning.
fips: true

Leaving it unset is not the same as false: unset follows the Go runtime’s own FIPS 140-3 mode, which is why the key ships with no value rather than set to false.