Security & Encryption
SolidPing stores sensitive data two ways, depending on whether the server ever needs to read it back:
- Encrypted at rest — notification connection secrets, check credentials, integration tokens: AES-256-GCM under an out-of-band master key you provide.
- Hashed at rest — session refresh tokens, API tokens, invitation, registration and password-reset links: only a one-way digest is stored, so a stolen database yields nothing that can be replayed.
Encryption uses an envelope scheme anchored by an out-of-band master key that you provide.
How It Works
- You supply a 32-byte master key (the Key Encryption Key) via the environment.
- SolidPing generates a random data encryption key per organization and stores it encrypted with the master key.
- Individual secrets are encrypted with their organization's data key using AES-256-GCM.
Because the master key lives outside the database, a database dump alone never reveals plaintext secrets.
Configuration
| Variable | Default | Description |
|---|---|---|
SP_ENCRYPTION_MASTER_KEY | - | Base64-encoded 32-byte master key (KEK) |
SP_ENCRYPTION_MASTER_KEY_FILE | - | Path to a file containing the base64 master key (wins over SP_ENCRYPTION_MASTER_KEY when both are set) |
SP_ENCRYPTION_AUTO_MIGRATE | true | Encrypt any existing plaintext credentials on startup |
Generate a key with:
openssl rand -base64 32
SP_ENCRYPTION_MASTER_KEY=$(openssl rand -base64 32)
For Kubernetes, mount the key as a secret file and point SP_ENCRYPTION_MASTER_KEY_FILE at it.
The master key is required to decrypt stored secrets. Back it up and keep it stable — losing it means losing access to every encrypted credential. Rotating it requires re-encrypting existing data.
Secrets Are Never Echoed Back
Secret fields (passwords, tokens, webhook secrets) are never returned to the dashboard or API after they're saved. Responses mask them with a placeholder and list which fields are secret, so the UI can show "a value is set" without exposing it. Secrets are decrypted server-side only when actually needed to perform a check or send a notification.
Tokens Are Stored Hashed
Encryption protects secrets the server must read again. A credential that is only ever presented — the server only checks whether the value it was given is the right one — is stored differently: as a SHA-256 hash of the value, never as the value itself.
| Stored as a hash | What it authenticates |
|---|---|
| Session refresh token | keeping a signed-in session alive |
API token (pat_…) | CLI and API access |
| MCP / OAuth refresh grant | long-lived OAuth access |
| Invitation link | joining an organization |
| Registration confirmation link | activating a new account |
| Password reset link | account recovery |
| SSO handoff code | the one-time exchange after an SSO round-trip |
| Status page kiosk token | TV / wallboard mode |
| Private-location agent enrollment token | pairing an agent |
A database dump alone is useless against this. There is no plaintext column to read: a backup, a replica or a SQL-injection read yields digests that cannot be replayed as a session, an API token or an invite link. That is the difference from the encryption above — encryption is reversible by anyone holding the master key, a hash is not reversible by anyone.
Lookups work the same way everywhere in the table: the value that was presented is hashed on the spot and matched against what is stored. For a single-use link the digest is the row's key, so the plaintext token from the link never lands on disk, and a digest on its own never authenticates anything.
Why SHA-256 and not argon2id: every value in that table is CSPRNG output of at least 192 bits — there is no dictionary to attack — and tokens are verified on hot paths where a memory-hard hash would cost a great deal and buy nothing. Passwords and OAuth client secrets are the opposite case (short, low-entropy, human-adjacent) and use argon2id — see Password Hashing.
Upgrades are transparent. Existing rows are hashed in place and the plaintext column is dropped in the same transaction, so sessions, API tokens and outstanding invitations keep working across an upgrade. Registration links still pending at upgrade time fail their confirmation once and must be requested again.
An earlier version expects plaintext tokens, finds only hashes and degrades by deleting them: every user is signed out and every API token and OAuth grant is revoked. Upgrade again and sign back in.
Not everything can be hashed. Notification connection secrets, webhook signing secrets, check credentials and integration tokens have to be re-read to be used — a hash cannot be turned back into a Slack token — so those are the encrypted ones described above. Hashing covers what the server only ever verifies.
Optional, with a Plaintext Fallback
If no master key is configured, encryption is disabled and credentials are stored as-is (the original behavior). Setting a master key enables encryption and — with SP_ENCRYPTION_AUTO_MIGRATE — transparently migrates existing data. Configuring the master key is strongly recommended for any production deployment.
Token hashing has no such fallback and no configuration: it is not keyed off the master key, and it never stores a token in plaintext.
API Tokens Are Bound to One Organization
An API token (pat_…) belongs to the organization it was created in, with your role there. It reads and writes that organization only: a request for another organization answers 403, even when you are a member of it.
A token is meant for a script, a CI job or the CLI, so it cannot change how your account signs in or open a session. These requests answer 403 PAT_FORBIDDEN:
- switching organization, creating an organization or accepting an invitation (each would open a session);
- approving a device sign-in (it would mint a token for another organization);
- changing your password or your sign-in email, setting up or removing 2FA, adding or removing a passkey;
- linking a Discord account, or a Discord or Telegram chat for paging.
To do any of that, sign in to the dashboard, or with sp auth login. A token can still create another token in its own organization.
Adding a passkey sends you an email, with a link to your account security page: a passkey signs in to every organization of the account and survives a password reset, so one you did not add must not go unnoticed. Turning on 2FA sends one too.
Adding a Sign-In Factor Asks Who You Are
Being signed in is not enough to add a way to sign in. Setting up 2FA, adding a passkey or setting a first password asks you to prove it is you again, so someone who got hold of a signed-in session cannot lock you out with their own authenticator, or keep a way in after you reset your password:
- An account with a password types its current password. A wrong password answers
401 INVALID_CURRENT_PASSWORD; the attempts share the budget of the password change form, and past it the answer is429. - An account without a password (it signs in through an identity provider or a passkey) must have signed in within the last 10 minutes. Otherwise the answer is
403 REAUTHENTICATION_REQUIREDand the dashboard asks you to sign in again. Refreshing a session does not count as signing in.
Over the API, send currentPassword in the body of POST /api/v1/auth/2fa/setup and POST /api/v1/auth/passkeys/register/begin. The setup returns a setupToken, valid 10 minutes, that POST /api/v1/auth/2fa/confirm needs with the code.
Status-Page Subscriptions Are Budgeted
Anyone can subscribe an email address to a public status page, with no account. The address then receives a confirm link (double opt-in). To keep that form from flooding a third party's mailbox from your mail domain, the confirm email has three hourly budgets:
| Variable | Default | Counts |
|---|---|---|
SP_STATUS_SUBSCRIBERS_CONFIRM_PER_RECIPIENT_PER_HOUR | 3 | emails to one address, across every status page |
SP_STATUS_SUBSCRIBERS_CONFIRM_PER_PAGE_PER_HOUR | 100 | emails sent by one status page |
SP_STATUS_SUBSCRIBERS_CONFIRM_PER_IP_PER_HOUR | 20 | emails one client IP can trigger |
0 turns a budget off. The client IP follows SP_SERVER_RATE_LIMITING_TRUSTED_PROXIES, like the rate limiter.
Over budget, the form still answers 202 but sends nothing. Subscribing an address that is already confirmed changes nothing and sends nothing either, with the same 202. So the form tells nobody whether an address is subscribed, and nobody can cancel someone else's subscription through it.
A confirm link expires after 7 days. The cleanup job then deletes the unconfirmed subscription.
Super-Admin Impersonation
A super admin can sign in as another user to see exactly what they see: Server → Users, then the eye icon on the user's row. It is meant for support and debugging, and it is built so it cannot become an account takeover:
- The session acts as the user, with their role in their organization. The admin's super-admin rights do not travel with it.
- It lasts 30 minutes and has no refresh token. When it ends, or when the admin clicks Exit in the banner, the dashboard goes back to the admin's own session.
- The user's own sessions are untouched: nothing is added to their session list and they are not signed out.
- It cannot change the user's password, email, profile, 2FA or passkeys, create API tokens or sessions in their name, revoke their sessions, or start another impersonation. Those requests answer
403 IMPERSONATION_FORBIDDEN. - Super admins, the shared demo account and yourself cannot be impersonated. A user with 2FA can be: the admin's own sign-in is what gates it.
- Every start is recorded as
auth.impersonation_startedin the user's organization audit log, and every event written during the session carriesimpersonated_bywith the admin's user ID.
The user is not notified by email. The audit log is the record.
To turn the feature off entirely:
| Variable | Default | Description |
|---|---|---|
SP_AUTH_IMPERSONATION_ENABLED | true | When false, POST /api/v1/system/users/:uid/impersonate answers 404 |