Skip to content

Email notifications

ssoosshd can email people when something happens to a credential they hold. It is off by default and adds nothing to the certificate flows: every path that emits a notification behaves identically with mail disabled.

Nothing a browser or an unattended job waits on ever waits on the mail relay. An approval or a redemption publishes an event to the internal queue and returns; rendering and SMTP happen on a background consumer. A relay that is slow, greylisting, or down delays no certificate work – it delays only the notification.

The “service code created” notification carries what the recipient needs to recognise the code – the service account, when it expires, who approved it and from where, and at the advanced level the bound key’s fingerprint – so the message is actionable without being redeemable. The ssh_config recipe stays in the terminal, which prints it with the real code and key path; a copy in mail could only carry placeholders. There is a test asserting the code does not appear in a rendered message, and another asserting it never reaches the notification payload; both cover every registered kind, so adding a field later is a decision rather than an accident.

The rule holds for the two later enrollment kinds too. The expiry reminder says to run ssoossh service enroll again rather than offering anything to reuse, and the expired-attempt report names the enrollment without naming the code that was presented.

What a “was this you?” message has to answer

Section titled “What a “was this you?” message has to answer”

The three *_certificate_issued kinds exist for one reader: the person deciding whether a certificate in their name was theirs. “A certificate was issued” is not answerable. What is answerable is what asked and who let it happen, so every message carries both.

What asked is the part of the host context a person recognises: the account and machine, who invoked it, and the address the request came from. A console login also always shows its terminal and, when there is one, the remote host it reports. At the advanced level the command line and terminal follow. The rest of the host context – the machine’s stable id, its platform, the client implementation and version, the process ids – stays on the certificate’s page, which the message links to; see Host context for where each value comes from.

Who let it happen is the approval, and the fact that answers it is the address it was approved from (the browser follows at the advanced level). The approver is the recipient, so the message does not name them back to themselves. This is the part that distinguishes the two bad outcomes from each other. A certificate you do not recognize approved from an address you were at is a machine you should look at; the same certificate approved from an address you have never used is an account someone else was driving.

service_enrollment_created names the approver, because its recipients are the account’s holders rather than the approver alone, and the address they approved from: it announces a credential that mints certificates unattended for as long as the code lives. RequestSourceIP there (advanced level) is the requester’s address and ApproverSourceIP the approver’s – two different questions that are easy to conflate.

Each message leads with one sentence of what happened, one line of what to do if it was not you, and one link to the page where you act on it: the certificate, or the service code, whose page has the expire control.

Two deployments are worth writing out. A local relay, where the mail system already on this host takes it from here: no TLS and no authentication are reasonable, because the connection never leaves the machine.

mail:
enabled: true
from: "ssoossh <no-reply@example.com>"
smtp:
host: "localhost"
port: 25
tls: "off"
auth: "none"

A remote submission service, where the connection crosses a network, so both TLS and authentication are strongly suggested:

mail:
enabled: true
from: "ssoossh <no-reply@example.com>"
reply_to: "ssh-admins@example.com"
subject_prefix: "[ssoossh] "
smtp:
host: "smtp.example.com"
port: 587
tls: "required"
auth: "plain"
username: "ssoossh@example.com"
password_file: "/run/secrets/ssoossh-smtp-password"
Key Default Notes
mail.enabled false with it false the notification handler is not registered at all, so no event is queued and nothing is rendered
mail.from empty the envelope and header sender. Required when enabled
mail.reply_to unset worth setting to a monitored address: these messages report credential activity, and the first thing a recipient does with an unexpected one is reply
mail.subject_prefix empty prepended to every rendered subject, so you can tag messages without editing a template
mail.template_dir unset local template overrides; see below
mail.smtp.host localhost required when mail is enabled
mail.smtp.port 25 conventionally 25 local, 587 submission with STARTTLS, 465 implicit TLS
mail.smtp.helo unset empty lets the library use the local hostname, which is right unless the relay checks it
mail.smtp.timeout 15s bounds one delivery’s connect-and-send. It bounds only the background sender, never a request

TLS and authentication are optional, and suggested

Section titled “TLS and authentication are optional, and suggested”

Neither is mandatory: the local-relay deployment legitimately needs neither, and forcing them would make the simplest working setup impossible. But the server warns at startup when it is relaying to a non-loopback host with tls: "off" or tls: "opportunistic" (which anyone on the path can strip), with auth: "none", or with insecure_skip_verify: true. Those warnings are the suggestion; they are not errors, and nothing fails because of them.

mail.smtp.tls accepts:

Value Meaning
off Never negotiate STARTTLS. Reasonable only for a loopback relay
opportunistic Use STARTTLS when offered, plaintext when not. The default
required Fail the delivery rather than send in plaintext
implicit Dial TLS directly (SMTPS, conventionally port 465)

mail.smtp.auth accepts none (the default), auto, plain, login, cram-md5, scram-sha-1, scram-sha-256, and xoauth2. auto negotiates the strongest mechanism the relay advertises. mail.smtp.username is required for every mechanism but none.

Two more keys cover a relay with a private CA or a name that does not match: mail.smtp.ca_file (empty uses the system trust store) and mail.smtp.server_name (needed only when host is an address rather than the name on the certificate).

Prefer mail.smtp.password_file to mail.smtp.password: it keeps the secret out of the config file, and out of the effective configuration auditors can read. It takes precedence when both are set, is read once at startup, and has trailing whitespace trimmed, since an editor’s newline is never part of a password.

Anything wrong here fails the server at startup rather than at first delivery – an unparseable from, an unreadable password file, a template that does not compile. A notification that never arrives looks exactly like one that was never triggered, which is the worst thing this feature could do quietly.

Delivery logs route separately, by a type=mail attribute like the LDAP and audit logs: mail.logging.level (default info) sets the threshold for what both halves of the mail path say, and setting a filename moves those records out of the general log into their own rotating file.

The delivery consumer joins a queue group, so exactly one instance sends each notification. Without that, a recipient would get one copy per running server. Nothing else about a multi-instance deployment needs configuring for mail – see Multi-instance and NATS for the notification.send subject.

The queue group deduplicates consumption, not publication, which matters for the two notifications not emitted from a single request path. The expiry reminder is found by a sweep every instance runs, and the expired-attempt report can be raised by whichever instance fields a retry. Both therefore claim their send in the database with a guarded UPDATE and publish only if that claims a row, so two instances produce one message between them rather than one each.

mail:
# How far ahead of an enrollment code's expiry the reminders start.
# 0 disables them and the sweep is not registered at all.
expiry_reminder_lead: 168h
# At most one "expired code used" message per enrollment per window.
# 0 disables that notification.
expired_attempt_window: 24h

mail.expiry_reminder_lead opens the window; inside it the reminder repeats weekly until the code is within its final week, then daily until it expires. A 30-day lead sends at 30, 23, 16 and 9 days out and then every day from 7 days out; the default week sends the daily ones alone. Each send is claimed in the database, so a multi-instance deployment sends each one once. Shortening the lead means a code now past the new window gets nothing until it re-enters it; lengthening it picks codes up at the next sweep. The sweep runs hourly (a lead shorter than a day sweeps at a fraction of it), and skips codes that have already expired – a reminder that something expired yesterday helps nobody, and the aftermath has its own notification.

mail.expired_attempt_window is a rate limit rather than a one-shot, because what it reports is a retry loop: a cron job holding a dead code fails on its own schedule indefinitely, and the recipient needs to keep hearing that it is still happening without hearing it every five minutes.

Notifications about a service enrollment – created, redeemed, expiring, and expired-code-used – go to every holder of its service account by default, resolved fresh at delivery from the accounts each user held at their last login.

That default leaves two gaps, and an optional per-enrollment notification address closes both:

  • A service account whose holders have never logged in has no rows to fan out to, so the notification reaches nobody.
  • An identity provider that releases no email claim silences every holder’s copy, and a large holder set turns every redemption into a mailshot where a team alias would do.

Set the address on the browser approval page when approving the request, or afterwards from the code’s own page under Service codes (any holder) or under Admin → Service codes (SOC). Clearing it restores fan-out.

A set address is the sole recipient and is sent ungated: with no single owning user there is no principled per-kind preference that could gate it, and the address is the account’s own subscription, entered deliberately. A holder who has opted out of a kind still does not get their own copy – they simply are not a recipient while an address is set.

There is deliberately no domain allowlist. Anyone who can set the address can already approve certificates for the account. Changing it is recorded in the audit stream as enrollment.notification_email_set, carrying both the old and the new value, because redirecting a credential’s mail is exactly the kind of quiet change an auditor wants to be able to find.

Each user chooses which notifications they receive at /preferences in the web UI. In a service-account fan-out each holder’s own choice gates their own copy, so one person opting out silences nothing for anyone else, and an account whose holders have all opted out sends nothing at all.

Choices are stored per (user, kind); a user who has never answered gets the kind’s own default, which is what lets a new notification ship without a backfill.

The three “was this you?” kinds – user_certificate_issued, pam_certificate_issued and console_certificate_issued – default off, and are the only ones that do. An interactive certificate is issued per login, a PAM certificate per sudo and a console certificate per console login, so any of them defaulting on would make every existing deployment noisy the day it upgrades. They are separate kinds rather than one with a type field so a user who runs sudo forty times a day and logs in twice can keep the login signal without drowning in the other. A security-sensitive deployment can tell its users to turn them on; nothing turns them on for them. The console kind is the one such a deployment should push hardest: a console request is made by an unauthenticated machine and approved by a code typed into the owner’s web session, so a message the owner does not recognize means that session was used by someone else.

The preference is read at delivery rather than at publication, so a notification queued moments before someone opts out is not delivered anyway.

One kind is not a preference at all. admin_auto_disabled tells an admin the directory sync has disabled their account, and an admin in that position has no session left to find out any other way. It is always sent: the preferences page does not list it, a request to turn it off is refused, and it arrives even when every other kind is off. It still honours the reader’s email detail level. Only mail.enabled being false, or an account with no address, stops it. See When the sync disables an admin for who counts as an admin.

Each user also chooses how much every message carries, on the same page: Simplified or Advanced. Simplified is the sentence, the link, and the few facts a person checks: when it stops being valid, where it was requested and approved from. Advanced adds a Details section with key IDs, serials, certificate options, the bound key, and the claimed command and terminal.

The level is the user’s own, stored on their account, and separate from the web UI’s Simplified / Advanced setting. It is set once, at the user’s first login after it existed: Advanced for members of the SOC or admin group, Simplified for everyone else. A later change of groups does not move it. A user who has not logged in since, and an enrollment’s notification address, which has no account behind it, get Simplified. A fan-out renders each level once and sends each holder their own.

Two things stop delivery regardless of the toggle, and the page says so rather than leaving the user to work it out: mail.enabled being false, and an identity whose provider releases no email claim.

When the directory gives a stored NSS account or group a different uid or gid, hosts keep the stored number until an admin accepts the change under Admin > Directory (see Frozen uid and gid). Two kinds tell the admins about it:

  • nss_id_change_pending lists what is new: one message per admin for everything a sync run made pending, sent when the run ends, and one for whatever on-demand and login reads made pending, sent by a sweep every five minutes. Each change is mailed once. A change accepted, or reverted in the directory, before the message goes out is not mailed, and a different new number for the same entry is mailed again as new.
  • nss_id_change_reminder lists everything still waiting, with how long each has waited, every Monday at 09:00 in the server’s local time zone, and only while something is waiting.

Both go to every admin: each user whose groups at their last login include admin.require_group, who has an email address, and who is not disabled. There is no address list to configure. Because the groups are the ones the last login recorded, someone removed from the admin group at the identity provider keeps getting these until they log in again, and a new admin gets them from their first login. A change that waits while no admin can be mailed is mailed as new once one can.

Both are preferences that default on, listed on the preferences page only to a session holding the admin role, so each admin can switch either off. Each message lists at most 50 changes, oldest first, says how many more there are, and links to the pending changes on the directory page. Every instance runs the sweep and the reminder; each change is claimed on its row and each week’s reminder on nss_alerts, so a restart or a second instance never repeats a message.

Sixteen kinds. Every one ships three templates, named <kind>.subject.tmpl, <kind>.txt.tmpl and <kind>.html.tmpl.

Kind Trigger Recipients Default
service_enrollment_created you approve a service certificate request and a service code is created every holder of the service account, or the notification address on
service_enrollment_redeemed one of the codes is redeemed for a certificate, including failed attempts every holder, or the notification address on
service_enrollment_expiring a code is close to expiring: weekly inside the reminder window, then daily over the final week every holder, or the notification address on
service_enrollment_expired_attempt an expired code is presented for redemption every holder, or the notification address on
user_certificate_issued an interactive SSH certificate is signed for you the person the certificate is for off
pam_certificate_issued a certificate is signed for a local sudo or su on your behalf the person the certificate is for off
console_certificate_issued a certificate is signed for a console login you approved with a typed code the person who approved off
admin_auto_disabled the directory sync disables an account whose most recent login held the admin role the admin whose account was disabled always sent
nss_key_expiring an NSS fleet key is close to expiring: weekly inside the reminder window, then daily over the final week the key’s notification address always sent
nss_key_rotation_cleanup after mail.rotation_quiet_period, daily while any host still presents a rotated key’s previous secret and its grace lasts the key’s notification address always sent
nss_key_expired_attempt a host presents an expired fleet key, at most once per mail.expired_attempt_window the key’s notification address always sent
nss_key_revoked_attempt a host presents a revoked fleet key, at most once per mail.expired_attempt_window the key’s notification address always sent
nss_key_rotated a fleet key is rotated the key’s notification address always sent
nss_key_revoked a fleet key is revoked the key’s notification address always sent
nss_id_change_pending the directory gives stored NSS uids or gids a different value: one message per sync run or per batch of reads, listing what is new every admin, as of their last login on
nss_id_change_reminder every Monday at 09:00 server time while any uid or gid change is still waiting every admin, as of their last login on

service_enrollment_created

Sent when you approve a service certificate request and a service code is created for it.

Default: on.

Templates:

  • service_enrollment_created.subject.tmpl
  • service_enrollment_created.txt.tmpl
  • service_enrollment_created.html.tmpl
Field Type Description
.ServiceAccount string The service account the enrollment was approved for. It is the sole principal of every certificate the code produces.
.RequestID string The certificate request this enrollment came from.
.EnrollmentID string The enrollment record’s own identifier, as shown in the retrieval log.
.KeyID string The SSH certificate key ID fixed at approval time.
.Principals []string The certificate principals fixed at approval time.
.PublicKeyFingerprint string SHA256 fingerprint of the enrolled public key. The code only ever produces certificates for this key.
.PublicKeyType string SSH algorithm of the enrolled public key, e.g. ssh-ed25519.
.Extensions []string SSH certificate extensions granted, after narrowing against server config.
.ForceCommand string The force-command critical option, or empty if none was granted.
.SourceAddresses []string The source-address critical option, or empty if unrestricted.
.NoTouchRequired bool Whether the no-touch-required extension was granted (hardware-backed sk- keys only).
.RequestSourceIP string The address the enrollment request was submitted from.
.ApprovedAt time.Time When the request was approved and the code minted.
.ApprovedByUsername string The username of the identity that approved the request.
.ApprovedByName string The approver’s human-readable name, or empty if none was captured.
.ApprovedByEmail string The approver’s email address, or empty if none was recorded.
.ApproverSourceIP string The address the approval was made from, observed by the server. A different question from RequestSourceIP, which is the requester’s.
.CodeExpiresAt time.Time When the enrollment code stops being redeemable. Re-enroll before this to keep an unattended job running.
.CertificateLifetime time.Duration How long each certificate redeemed from this code is valid for, measured from each redemption.
.ServerURL string The server’s public origin, for links back to the request.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

service_enrollment_redeemed

Sent every time one of your service codes is redeemed for a certificate, including failed attempts.

Default: on.

Templates:

  • service_enrollment_redeemed.subject.tmpl
  • service_enrollment_redeemed.txt.tmpl
  • service_enrollment_redeemed.html.tmpl
Field Type Description
.ServiceAccount string The service account the redeemed certificate is for.
.RequestID string The certificate request the enrollment came from, or empty for an enrollment with no linked request.
.EnrollmentID string The enrollment whose code was redeemed.
.RetrievalID string This redemption’s own identifier, matching the row in the retrieval log.
.SourceIP string The address the redemption was made from.
.RetrievedAt time.Time When the code was redeemed.
.CertificateSerial uint64 Serial of the certificate issued for this redemption.
.CertificateExpiresAt time.Time When the issued certificate stops being valid.
.KeyID string The SSH certificate key ID carried by the issued certificate.
.Principals []string The issued certificate’s principals.
.Succeeded bool False when the code was valid but signing failed; the failure detail is in the server log.
.FirstRedemption bool True when this was the first time the code was redeemed.
.CodeExpiresAt time.Time When the code itself stops being redeemable.
.ServerURL string The server’s public origin, for links back to the retrieval log.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

service_enrollment_expiring

Sent while one of your service codes is close to expiring, so an unattended job can be enrolled again before it starts failing: weekly inside the reminder window, then daily over the final week.

Default: on.

Templates:

  • service_enrollment_expiring.subject.tmpl
  • service_enrollment_expiring.txt.tmpl
  • service_enrollment_expiring.html.tmpl
Field Type Description
.ServiceAccount string The service account the expiring enrollment belongs to.
.RequestID string The certificate request the enrollment came from, or empty for an enrollment with no linked request.
.EnrollmentID string The enrollment about to expire.
.KeyID string The SSH certificate key ID fixed at approval time.
.Principals []string The certificate principals fixed at approval time.
.PublicKeyFingerprint string SHA256 fingerprint of the enrolled public key.
.PublicKeyType string SSH algorithm of the enrolled public key, e.g. ssh-ed25519.
.FirstRedeemedAt time.Time When the code was first redeemed, or the zero time if it never was. A code never redeemed is usually a job that was never finished.
.CodeExpiresAt time.Time When the code stops being redeemable. Re-enroll before this.
.Daily bool True once the code is inside its final week and reminders come daily; false while they are still weekly.
.ServerURL string The server’s public origin, for links back to the enrollment.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

service_enrollment_expired_attempt

Sent when an expired service code is presented for redemption: either a job is still trying to use it, or someone is replaying a credential that should no longer exist.

Default: on.

Templates:

  • service_enrollment_expired_attempt.subject.tmpl
  • service_enrollment_expired_attempt.txt.tmpl
  • service_enrollment_expired_attempt.html.tmpl
Field Type Description
.ServiceAccount string The service account the expired enrollment belongs to.
.RequestID string The certificate request the enrollment came from, or empty for an enrollment with no linked request.
.EnrollmentID string The enrollment whose expired code was presented.
.KeyID string The SSH certificate key ID fixed at approval time.
.Principals []string The certificate principals fixed at approval time.
.PublicKeyFingerprint string SHA256 fingerprint of the enrolled public key.
.PublicKeyType string SSH algorithm of the enrolled public key, e.g. ssh-ed25519.
.SourceIP string The address the attempt came from.
.AttemptedAt time.Time When the expired code was presented.
.CodeExpiredAt time.Time When the code stopped being redeemable.
.ServerURL string The server’s public origin, for links back to the enrollment.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

user_certificate_issued

Sent every time an interactive SSH certificate is signed for you. Off by default: this is one message per login, for people who want to see every one.

Default: off.

Templates:

  • user_certificate_issued.subject.tmpl
  • user_certificate_issued.txt.tmpl
  • user_certificate_issued.html.tmpl
Field Type Description
.CertificateType string The certificate type: “user”, “pam” or “console”.
.RequestID string The certificate request this certificate was issued for.
.CertificateID string The certificate’s own record, for a link to /certs/. Empty if the record could not be read back.
.KeyID string The SSH certificate key ID.
.Principals []string The accounts this certificate may log in as.
.Serial uint64 The certificate serial, matching the entry in your certificate history.
.PublicKeyFingerprint string SHA256 fingerprint of the key the certificate was issued for.
.LocalUsername string The local account the client reported, or empty if it reported none. Client-reported, so not evidence.
.LocalHostname string The machine the client reported, or empty if it reported none. Client-reported, so not evidence.
.RequestingUser string Who invoked the request, when that differs from the account it ran as: PAM_RUSER for a module, SUDO_USER for the client. Client-reported, so not evidence.
.Process string The command line that asked, e.g. “sudo systemctl restart nginx” or “ssoossh ssh login”. Client-reported, so not evidence.
.TTY string The terminal the request came from, or empty if it had none. Client-reported, so not evidence.
.RemoteHost string The peer address the requester reported, non-empty only when it was itself reached over the network. Client-reported, so not evidence.
.PAMService string The PAM service that asked (sudo, su, sshd, login). Empty on a user certificate, which has none.
.MachineID string The requesting machine’s stable installation id, which survives a rename. Client-reported, so not evidence.
.OS string The platform as the requesting machine describes itself. Client-reported, so not evidence.
.Client string The implementation and version that sent the request, e.g. “ssoossh/1.2.3” or “pam_ssoossh-c/0.3.0”.
.ClientMode string The PAM module’s configured mode argument (auto, sudo, console). Empty for the client, which has none.
.CallerUID *int64 The requesting process’s user id, or absent if it reported none. A pointer because uid 0 is a value.
.CallerGID *int64 The requesting process’s group id, or absent if it reported none.
.CallerPID *int64 The requesting process’s id, for joining against the machine’s own logs.
.CallerPPID *int64 The requesting process’s parent id.
.SourceIP string The address the request was made from. Observed by the server, unlike everything the client reported.
.IssuedAt time.Time When the certificate becomes valid.
.ExpiresAt time.Time When the certificate stops being valid.
.ApprovedByUsername string The account that approved the request, or empty if no decision record survives.
.ApprovedByName string The approver’s human-readable name, or empty if none was captured.
.ApprovedByEmail string The approver’s email address, or empty if none was recorded.
.ApproverSourceIP string The address the approval was made from, observed by the server. On the unhappy path this is the fact that says whose session was used.
.ApproverUserAgent string The browser that approved, as it identified itself.
.ApprovedAt time.Time When the approval happened. Zero if no decision record survives.
.Extensions []string SSH certificate extensions granted, after narrowing against server config.
.ForceCommand string The force-command critical option, or empty if none was granted.
.SourceAddresses []string The source-address critical option, or empty if unrestricted.
.NoTouchRequired bool Whether the certificate waives the hardware-key touch, which makes it a weaker credential than one that does not.
.ServerURL string The server’s public origin, for links back to the certificate.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

pam_certificate_issued

Sent every time a certificate is signed for a local sudo or su on your behalf. Off by default: this is one message per sudo.

Default: off.

Templates:

  • pam_certificate_issued.subject.tmpl
  • pam_certificate_issued.txt.tmpl
  • pam_certificate_issued.html.tmpl
Field Type Description
.CertificateType string The certificate type: “user”, “pam” or “console”.
.RequestID string The certificate request this certificate was issued for.
.CertificateID string The certificate’s own record, for a link to /certs/. Empty if the record could not be read back.
.KeyID string The SSH certificate key ID.
.Principals []string The accounts this certificate may log in as.
.Serial uint64 The certificate serial, matching the entry in your certificate history.
.PublicKeyFingerprint string SHA256 fingerprint of the key the certificate was issued for.
.LocalUsername string The local account the client reported, or empty if it reported none. Client-reported, so not evidence.
.LocalHostname string The machine the client reported, or empty if it reported none. Client-reported, so not evidence.
.RequestingUser string Who invoked the request, when that differs from the account it ran as: PAM_RUSER for a module, SUDO_USER for the client. Client-reported, so not evidence.
.Process string The command line that asked, e.g. “sudo systemctl restart nginx” or “ssoossh ssh login”. Client-reported, so not evidence.
.TTY string The terminal the request came from, or empty if it had none. Client-reported, so not evidence.
.RemoteHost string The peer address the requester reported, non-empty only when it was itself reached over the network. Client-reported, so not evidence.
.PAMService string The PAM service that asked (sudo, su, sshd, login). Empty on a user certificate, which has none.
.MachineID string The requesting machine’s stable installation id, which survives a rename. Client-reported, so not evidence.
.OS string The platform as the requesting machine describes itself. Client-reported, so not evidence.
.Client string The implementation and version that sent the request, e.g. “ssoossh/1.2.3” or “pam_ssoossh-c/0.3.0”.
.ClientMode string The PAM module’s configured mode argument (auto, sudo, console). Empty for the client, which has none.
.CallerUID *int64 The requesting process’s user id, or absent if it reported none. A pointer because uid 0 is a value.
.CallerGID *int64 The requesting process’s group id, or absent if it reported none.
.CallerPID *int64 The requesting process’s id, for joining against the machine’s own logs.
.CallerPPID *int64 The requesting process’s parent id.
.SourceIP string The address the request was made from. Observed by the server, unlike everything the client reported.
.IssuedAt time.Time When the certificate becomes valid.
.ExpiresAt time.Time When the certificate stops being valid.
.ApprovedByUsername string The account that approved the request, or empty if no decision record survives.
.ApprovedByName string The approver’s human-readable name, or empty if none was captured.
.ApprovedByEmail string The approver’s email address, or empty if none was recorded.
.ApproverSourceIP string The address the approval was made from, observed by the server. On the unhappy path this is the fact that says whose session was used.
.ApproverUserAgent string The browser that approved, as it identified itself.
.ApprovedAt time.Time When the approval happened. Zero if no decision record survives.
.Extensions []string SSH certificate extensions granted, after narrowing against server config.
.ForceCommand string The force-command critical option, or empty if none was granted.
.SourceAddresses []string The source-address critical option, or empty if unrestricted.
.NoTouchRequired bool Whether the certificate waives the hardware-key touch, which makes it a weaker credential than one that does not.
.ServerURL string The server’s public origin, for links back to the certificate.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

console_certificate_issued

Sent every time a certificate is signed for a console login you approved with a typed code. Off by default: this is one message per login.

Default: off.

Templates:

  • console_certificate_issued.subject.tmpl
  • console_certificate_issued.txt.tmpl
  • console_certificate_issued.html.tmpl
Field Type Description
.CertificateType string The certificate type: “user”, “pam” or “console”.
.RequestID string The certificate request this certificate was issued for.
.CertificateID string The certificate’s own record, for a link to /certs/. Empty if the record could not be read back.
.KeyID string The SSH certificate key ID.
.Principals []string The accounts this certificate may log in as.
.Serial uint64 The certificate serial, matching the entry in your certificate history.
.PublicKeyFingerprint string SHA256 fingerprint of the key the certificate was issued for.
.LocalUsername string The local account the client reported, or empty if it reported none. Client-reported, so not evidence.
.LocalHostname string The machine the client reported, or empty if it reported none. Client-reported, so not evidence.
.RequestingUser string Who invoked the request, when that differs from the account it ran as: PAM_RUSER for a module, SUDO_USER for the client. Client-reported, so not evidence.
.Process string The command line that asked, e.g. “sudo systemctl restart nginx” or “ssoossh ssh login”. Client-reported, so not evidence.
.TTY string The terminal the request came from, or empty if it had none. Client-reported, so not evidence.
.RemoteHost string The peer address the requester reported, non-empty only when it was itself reached over the network. Client-reported, so not evidence.
.PAMService string The PAM service that asked (sudo, su, sshd, login). Empty on a user certificate, which has none.
.MachineID string The requesting machine’s stable installation id, which survives a rename. Client-reported, so not evidence.
.OS string The platform as the requesting machine describes itself. Client-reported, so not evidence.
.Client string The implementation and version that sent the request, e.g. “ssoossh/1.2.3” or “pam_ssoossh-c/0.3.0”.
.ClientMode string The PAM module’s configured mode argument (auto, sudo, console). Empty for the client, which has none.
.CallerUID *int64 The requesting process’s user id, or absent if it reported none. A pointer because uid 0 is a value.
.CallerGID *int64 The requesting process’s group id, or absent if it reported none.
.CallerPID *int64 The requesting process’s id, for joining against the machine’s own logs.
.CallerPPID *int64 The requesting process’s parent id.
.SourceIP string The address the request was made from. Observed by the server, unlike everything the client reported.
.IssuedAt time.Time When the certificate becomes valid.
.ExpiresAt time.Time When the certificate stops being valid.
.ApprovedByUsername string The account that approved the request, or empty if no decision record survives.
.ApprovedByName string The approver’s human-readable name, or empty if none was captured.
.ApprovedByEmail string The approver’s email address, or empty if none was recorded.
.ApproverSourceIP string The address the approval was made from, observed by the server. On the unhappy path this is the fact that says whose session was used.
.ApproverUserAgent string The browser that approved, as it identified itself.
.ApprovedAt time.Time When the approval happened. Zero if no decision record survives.
.Extensions []string SSH certificate extensions granted, after narrowing against server config.
.ForceCommand string The force-command critical option, or empty if none was granted.
.SourceAddresses []string The source-address critical option, or empty if unrestricted.
.NoTouchRequired bool Whether the certificate waives the hardware-key touch, which makes it a weaker credential than one that does not.
.ServerURL string The server’s public origin, for links back to the certificate.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

Admin account disabled by the directory sync

Section titled “Admin account disabled by the directory sync”

admin_auto_disabled

Sent to an admin when the scheduled directory sync disables their account because its directory entry has been missing for longer than ldap.sync.disable_after. Always sent: it cannot be turned off, and it is not sent for a disable a SOC user or an admin made by hand.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • admin_auto_disabled.subject.tmpl
  • admin_auto_disabled.txt.tmpl
  • admin_auto_disabled.html.tmpl
Field Type Description
.Username string The username of the account that was disabled.
.DisabledAt time.Time When the sync disabled the account.
.MissingSince time.Time When the sync first searched successfully and found no directory entry for the account.
.MissingFor time.Duration How long the entry had been missing when the account was disabled.
.DisableAfter time.Duration The configured ldap.sync.disable_after: how long an entry may stay missing before the sync disables the account.
.DirectoryDN string The last DN the account’s entry was found at, or empty if none was recorded.
.BaseDN string The configured ldap.base_dn the sync searches under.
.Reenable bool Whether ldap.sync.reenable is on, in which case the sync clears this disable by itself once the entry is found again.
.SupportEmail string Who to ask about this deployment (branding.support_email), or empty if none is configured.
.ServerURL string The server’s public origin, naming the deployment the account belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

userinfo_paused

Sent to every admin when the server stops calling the identity provider’s UserInfo endpoint after 10 failed calls in a row. Logins carry on with the ID token’s claims alone, and one login every 15 minutes retries the call. Always sent: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • userinfo_paused.subject.tmpl
  • userinfo_paused.txt.tmpl
  • userinfo_paused.html.tmpl
Field Type Description
.Endpoint string The UserInfo endpoint the provider’s discovery document names.
.PausedAt time.Time When the server paused the calls.
.ConsecutiveFailures int How many calls in a row had failed, across every instance.
.LastError string The last failed call’s error, with the access token removed.
.NextProbeAt time.Time When the first retry may run: the first login after this time makes the call.
.ServerURL string The server’s public origin, naming the deployment.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

userinfo_resumed

Sent to every admin when a retry of the identity provider’s UserInfo endpoint succeeds after a pause, and logins read its claims again. Always sent: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • userinfo_resumed.subject.tmpl
  • userinfo_resumed.txt.tmpl
  • userinfo_resumed.html.tmpl
Field Type Description
.Endpoint string The UserInfo endpoint the provider’s discovery document names.
.PausedAt time.Time When the server had paused the calls.
.ResumedAt time.Time When the retry succeeded and the calls resumed.
.ServerURL string The server’s public origin, naming the deployment.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_expiring

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) while a fleet key nears expiry: weekly inside mail.expiry_reminder_lead, then daily over the final week. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_expiring.subject.tmpl
  • nss_key_expiring.txt.tmpl
  • nss_key_expiring.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.Description string The key’s description, or empty if none was set.
.GroupName string The group whose members manage the key, or empty for none.
.ExpiresAt time.Time When the key stops being accepted. Extend it, or create a replacement, before this.
.Daily bool True once the key is inside its final week and reminders come daily; false while they are still weekly.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_rotation_cleanup

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) after a rotation, once mail.rotation_quiet_period has passed, and daily while any host still presents the previous secret, until the grace period ends. It lists the addresses that have not switched. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_rotation_cleanup.subject.tmpl
  • nss_key_rotation_cleanup.txt.tmpl
  • nss_key_rotation_cleanup.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.Generation int The key’s current generation, the one hosts should present.
.RotatedAt time.Time When the key was last rotated.
.PreviousExpiresAt time.Time When the previous secret stops working. Equal to RotatedAt when no grace period is set.
.Switched int Addresses whose last lookup presented the current secret.
.NotSwitched int Addresses whose last lookup presented the previous secret.
.Gone int Addresses seen before the rotation and not since.
.NotSwitchedAddresses []string Up to 50 addresses that have not switched, most recently seen first.
.MoreNotSwitched int How many more not-switched addresses there are beyond the listed ones, or zero.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_expired_attempt

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) when a host presents an expired fleet key, at most once per mail.expired_attempt_window. The attempt is refused. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_expired_attempt.subject.tmpl
  • nss_key_expired_attempt.txt.tmpl
  • nss_key_expired_attempt.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.SourceIP string The address the attempt came from, as the server observed it.
.AttemptedAt time.Time When the expired key was presented.
.ExpiredAt time.Time When the key stopped being accepted.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_revoked_attempt

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) when a host presents a revoked fleet key, at most once per mail.expired_attempt_window. The attempt is refused. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_revoked_attempt.subject.tmpl
  • nss_key_revoked_attempt.txt.tmpl
  • nss_key_revoked_attempt.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.SourceIP string The address the attempt came from, as the server observed it.
.AttemptedAt time.Time When the revoked key was presented.
.RevokedAt time.Time When the key was revoked.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_rotated

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) at once when a fleet key is rotated. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_rotated.subject.tmpl
  • nss_key_rotated.txt.tmpl
  • nss_key_rotated.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.Generation int The new current generation.
.RotatedAt time.Time When the key was rotated.
.RotatedByUsername string The account that rotated the key.
.RotatedByName string That account’s human-readable name, or empty if none was captured.
.PreviousExpiresAt time.Time When the previous secret stops working. Equal to RotatedAt when no grace period is set or the key had expired.
.ExpiresAt time.Time The key’s expiry as the rotation renewed it: its own lifetime again from the rotation, at most nss.key_max_lifetime. Zero for a key that never expires.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_key_revoked

Sent to the key’s notification address (nss_keys.notification_email, which defaults to the creator’s address) at once when a fleet key is revoked. Always sent, and not a preference: it cannot be turned off.

Always sent: it is not a preference and cannot be turned off.

Templates:

  • nss_key_revoked.subject.tmpl
  • nss_key_revoked.txt.tmpl
  • nss_key_revoked.html.tmpl
Field Type Description
.KeyID string The fleet key’s id, “nss_” followed by 8 characters.
.KeyName string The fleet key’s name.
.RevokedAt time.Time When the key was revoked.
.RevokedByUsername string The account that revoked the key.
.RevokedByName string That account’s human-readable name, or empty if none was captured.
.ServerURL string The server’s public origin, naming the deployment the key belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

nss_id_change_pending

Sent to every admin when the directory gives a stored NSS account or group a different uid or gid. Hosts keep the stored number until an admin accepts the change. One message per sync run, or per batch of on-demand and login reads, listing only the changes that are new.

Default: on.

Templates:

  • nss_id_change_pending.subject.tmpl
  • nss_id_change_pending.txt.tmpl
  • nss_id_change_pending.html.tmpl
Field Type Description
.Changes []notify.NSSIDChange The listed changes, oldest first, at most 50. Each has Entry (“account” or “group”), Attribute (“uid” or “gid”), Name, DN, DirectoryID (the ldap.id_attribute value, or empty), Stored (the number hosts are served), Pending (the directory’s new number), PendingSince (when the server first saw it) and Waiting (how long it has waited, as a time.Duration). An account whose uid and gid both changed is two changes.
.Total int How many changes the message is about, the listed ones included.
.More int How many changes there are beyond the listed ones, or zero.
.DirectoryURL string The pending changes on the admin directory page, where an admin accepts them: ServerURL followed by /admin/directory#pending-id-changes.
.ServerURL string The server’s public origin, naming the deployment the directory belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

Weekly reminder of waiting directory id changes

Section titled “Weekly reminder of waiting directory id changes”

nss_id_change_reminder

Sent to every admin on Monday at 09:00 server time while any uid or gid change is still waiting, listing all of them with how long each has waited.

Default: on.

Templates:

  • nss_id_change_reminder.subject.tmpl
  • nss_id_change_reminder.txt.tmpl
  • nss_id_change_reminder.html.tmpl
Field Type Description
.Changes []notify.NSSIDChange The listed changes, oldest first, at most 50. Each has Entry (“account” or “group”), Attribute (“uid” or “gid”), Name, DN, DirectoryID (the ldap.id_attribute value, or empty), Stored (the number hosts are served), Pending (the directory’s new number), PendingSince (when the server first saw it) and Waiting (how long it has waited, as a time.Duration). An account whose uid and gid both changed is two changes.
.Total int How many changes the message is about, the listed ones included.
.More int How many changes there are beyond the listed ones, or zero.
.DirectoryURL string The pending changes on the admin directory page, where an admin accepts them: ServerURL followed by /admin/directory#pending-id-changes.
.ServerURL string The server’s public origin, naming the deployment the directory belongs to.
.Advanced bool True when the recipient chose the advanced email detail level, false for simplified. Set per recipient at delivery; a template that ignores it sends the same message at both levels.

Every message ships as a built-in template compiled into the binary. Point mail.template_dir at a directory to replace any of them:

mail:
template_dir: "/etc/ssoossh/mail-templates"

A file there replaces the built-in template of the same name. Anything absent falls back to the built-in one, so overriding a single message does not mean vendoring the whole set – and a message added in a later release keeps working without you touching the directory.

Files are named <kind>.<part>.tmpl, where <part> is one of:

Part Engine Purpose
subject text/template The subject line. Rendered to one line: any line break is folded to a space, so nothing interpolated into it can inject headers. mail.subject_prefix is prepended afterwards
txt text/template The plain-text body
html html/template The HTML body, sent as an alternative. Interpolated values are HTML-escaped

Both bodies are always sent. A text-only reader gets the whole message rather than a notice telling them to view it elsewhere.

The field tables above list what each template may reference. Everything in a notification’s payload appears there – a test fails when the tables and the payload structs disagree in either direction, so a field that exists is a field you can render, and one you can render is one that is documented.

Optional fields are the ones to guard. A user certificate has no .PAMService, a local terminal has no .RemoteHost, a Windows client reports no .CallerUID, and a certificate whose decision record has gone has no .ApprovedByUsername. Wrap each in an {{ if }} rather than assuming it is there:

{{ if .Process }} Command {{ .Process }}
{{ end }}

The .Caller* fields are pointers, so {{ if .CallerUID }} is false when the platform reported none and true when it reported zero – and {{ .CallerUID }} prints the number, not an address. Times are time.Time, so {{ if not .ApprovedAt.IsZero }} is how you tell “not recorded” from a real timestamp.

In addition, these functions are available in every template:

Function Example Renders
datetime {{ datetime .ApprovedAt }} 2026-08-24 15:04 CEST, or not set for a zero time
date {{ date .CodeExpiresAt }} 2026-11-22, or not set
approx {{ approx .CertificateLifetime }} 8 hours, 90 days – the largest unit that still says something, rounded to the nearest one
until {{ until .CodeExpiresAt }} 89 days, or already elapsed
remaining {{ remaining .ExpiresAt }} ~8h left, or expired – one rounded unit, in the compact form the web UI prints beside a validity period
join {{ join .Principals ", " }} deploy-bot, deploy-bot-2

Timestamps render in the server’s local zone, with the zone named.

Every override is parsed and executed against an empty payload, at both detail levels, when the server starts. A syntax error, or a reference to a field that does not exist, stops the server rather than producing mail that silently stops arriving – including one inside an {{ if .Advanced }} block, which a single run would skip. An override that never reads .Advanced sends the same message at both levels. A file in the directory whose name matches no notification is also a startup error – a misspelled override would otherwise appear to do nothing. Files that are not *.tmpl are ignored, so a README or an editor backup beside your overrides is fine.

The built-in templates are the best starting point for an override: copy one out, edit it, and drop it in the override directory under the same name. Reference copies ship with the server, so you do not need a source tree:

Install Where the reference copies are
.deb / .rpm / .apk /usr/share/ssoossh/mail-templates/
Container image /usr/share/ssoossh/mail-templates/
Release archive mail-templates/ inside the tarball
Source tree server/resources/mail/

That is a code change rather than a configuration one: a Kind constant, a payload struct, a registry Definition, and three templates, after which the preferences page, the reference table, and delivery all follow from the registry. The steps are documented for developers in Adding a notification kind in the repository.