Authentication
AUTH_MODE picks the primary sign-in path, and the app build follows it. There is no username-and-password sign-in, and no Google or Microsoft social login. The email-code endpoints stay mounted in every mode, so an SSO deployment that does not need mail should leave RESEND_API_KEY unset. Personal access tokens, the command-line device grant and optional anonymous sessions sit alongside the primary path.
Pick a method
| Method | Setting | You provide | Use it when |
|---|---|---|---|
| OIDC single sign-on | AUTH_MODE=oidc |
An issuer URL, a client ID and a client secret | Your organization already has an identity provider. Start here. |
| SAML single sign-on | AUTH_MODE=saml |
A sign-on URL, two entity IDs and a certificate | Your identity provider speaks SAML 2.0 and not OIDC. |
| Email sign-in code | AUTH_MODE=consumer |
A transactional email service | You have no identity provider. Read the limitations below before choosing this. |
We recommend OIDC wherever you have the choice. Both SSO modes put sign-in and access control with your identity provider; OIDC is the simpler of the two to configure.
Every deployment path (Docker Compose, Kubernetes, AWS) ships with oidc set and a Keycloak container preloaded with a realm and a [email protected] / demo user, so sign-in works on first boot. Replace that with your own provider before real users arrive.
The mode is set in two places
| Where | Setting | Values |
|---|---|---|
| Server | AUTH_MODE |
consumer, oidc, saml |
| App build | VITE_AUTH_MODE (a build argument) |
sso for OIDC or SAML, unset for email codes |
An unset AUTH_MODE falls back to consumer, so set it explicitly. VITE_AUTH_MODE is baked into the app at build time rather than read at runtime, so switching between SSO and email sign-in means rebuilding the app image. The server refuses to start if the chosen mode’s settings are incomplete.
OIDC
What you configure
| Variable | Required | What it is |
|---|---|---|
AUTH_MODE |
yes | Set to oidc |
OIDC_ISSUER |
yes | Your provider’s issuer URL, as it appears in tokens |
OIDC_CLIENT_ID |
yes | The client (application) registered with your provider |
OIDC_CLIENT_SECRET |
yes | That client’s secret |
OIDC_DISCOVERY_URL |
no | Override when the server reaches the provider at a different hostname |
TRUSTED_ORIGINS |
in practice | Comma-separated list. Has a default, but sign-in fails unless it includes your provider’s origin. |
Any provider serving standard discovery at {OIDC_ISSUER}/.well-known/openid-configuration works: Keycloak, Okta, Auth0, Microsoft Entra ID, and others.
AUTH_MODE=oidcOIDC_ISSUER=https://login.example.com/realms/thunderboltOIDC_CLIENT_ID=thunderbolt-appOIDC_CLIENT_SECRET=<from your provider>TRUSTED_ORIGINS=https://thunderbolt.example.com,https://login.example.comWhat to register with your provider
One redirect URI, built from BETTER_AUTH_URL (the public URL of the server itself, which is not always the same host as the app):
<BETTER_AUTH_URL>/v1/api/auth/sso/callback/ssoRequest the standard openid, email and profile scopes. Thunderbolt identifies a user by email address.
Two hostnames for one provider
Inside a container network the server often reaches the provider at an internal address (http://keycloak:8080) while browsers reach it at a public one. Tokens carry the public issuer, so set OIDC_ISSUER to the browser-facing URL and OIDC_DISCOVERY_URL to the internal one:
OIDC_ISSUER=https://login.example.com/realms/thunderboltOIDC_DISCOVERY_URL=http://keycloak:8080/realms/thunderbolt/.well-known/openid-configurationPut both origins in TRUSTED_ORIGINS. The server validates discovery and metadata URLs against that list and refuses anything not on it.
SAML
What you configure
| Variable | Required | What it is |
|---|---|---|
AUTH_MODE |
yes | Set to saml |
SAML_ENTRY_POINT |
yes | Your provider’s sign-on URL, where users are sent to authenticate |
SAML_ENTITY_ID |
yes | Thunderbolt’s own entity ID. Must match the application you register with your provider. |
SAML_IDP_ISSUER |
yes | Your provider’s entity ID. Assertions are checked against it. |
SAML_CERT |
yes | Your provider’s signing certificate |
TRUSTED_ORIGINS |
no | Needed only if you pass a callbackURL on another origin; SAML has no discovery step to validate |
AUTH_MODE=samlSAML_ENTRY_POINT=https://login.example.com/sso/samlSAML_ENTITY_ID=thunderbolt-saml-spSAML_IDP_ISSUER=https://login.example.comSAML_CERT=MIIDazCCAlOgAwIBAgI...TRUSTED_ORIGINS=https://thunderbolt.example.com,https://login.example.comSAML_CERT is the raw base64 body of the certificate. Strip the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- lines, or validation fails with a certificate error.
What to register with your provider
Point your provider at Thunderbolt’s service-provider metadata, which the server publishes at:
<BETTER_AUTH_URL>/v1/api/auth/sso/saml2/sp/metadata?providerId=ssoIf your provider needs the assertion consumer URL entered by hand:
<BETTER_AUTH_URL>/v1/api/auth/sso/saml2/sp/acs/ssoAssertions must be signed. Thunderbolt validates the signature against SAML_CERT and the issuer against SAML_IDP_ISSUER.
What single sign-on looks like to the user
There is no Thunderbolt login form. Opening the app with no session sends the browser straight to your identity provider.
If a user already has a Thunderbolt account with the same email address, signing in through your provider links to that existing account rather than creating a second one.
Signing out of Thunderbolt ends the Thunderbolt session and leaves the one your identity provider holds in place, so the next visit may re-authenticate silently. That session ends at the provider.
Replacing the bundled Keycloak
The shipped Keycloak’s realm, client secret, admin password and demo user are all published in the Thunderbolt repository.
On Docker Compose, delete the keycloak service from the Compose file and set your own OIDC or SAML values. On Kubernetes, point the backend settings at your provider and disable the demo user with keycloak.demoUserEnabled: false. That change needs the Keycloak pod restarted, because the realm file is only read at startup.
The AWS stack installs that same chart only when you set platform to k8s. On the default Fargate platform Keycloak runs as an ECS task from a prebuilt image whose realm, demo user included, is baked in at build time, so there is no demoUserEnabled to set: point OIDC_* at your own provider and remove the Keycloak service instead.
Whichever path you are on, the bundled Keycloak stores nothing outside its own container and re-imports its realm from a file on startup. Anything you configure in its admin console is lost when the container is replaced.
Email sign-in codes
Under AUTH_MODE=consumer a user types an email address and receives an 8-digit code, sent both as a code to type and as a link to click. Either works.
An address with no account gets a code only once it is approved; otherwise it is put on a waitlist and sent a waitlist email instead. Approve whole domains with WAITLIST_AUTO_APPROVE_DOMAINS, or one address by setting its waitlist row to status = 'approved'. Configuration has the details.
| Behaviour | Value |
|---|---|
| Code length | 8 digits |
| Valid for | 10 minutes |
| Attempts per code | 3, then the code is dead |
| Resend | Re-sends the same code, so it cannot reset the attempt counter |
| Requests per address | One every 15 seconds |
Typing the code also requires a challenge token issued alongside it, so the eight digits on their own are not enough. That token is tied to the email address rather than to one browser, and the emailed link carries it, so treat the message itself as the credential.
Before choosing this mode
The sender address is not configurable. Sign-in email is sent through Resend using a fixed Thunderbolt sender domain, so a self-hosted deployment cannot currently send these emails under its own domain. We recommend single sign-on instead.
In production mode a server with no email service refuses the sign-in request outright, with an “Email service not configured” error.
Outside production mode it writes the code and the sign-in link to its own logs instead of sending them. Anyone who can read your logs can sign in as anyone.
Desktop and mobile
On desktop, OIDC and SAML sign-in opens your system browser: you authenticate there, and the browser hands the session back to the app over a local connection on the same machine. Email sign-in happens entirely inside the app.
That browser handoff needs one of the ports 17421, 17422 or 17423 free on the user’s own machine. A listener opens on one of them only during a sign-in, and only the same machine connects to it. A local firewall blocking all three breaks desktop sign-in, with no fallback.
Which server the apps talk to is fixed when they are built. Pointing desktop or mobile users at your deployment means producing your own builds with your API and app URLs, and rebuilding when you change the authentication mode. The browser app has no such constraint.
The sign-in link in an email opens the hosted Thunderbolt app directly on iOS and Android. For a self-hosted deployment that link opens in the browser instead, which still signs the user in.
Other ways in
Command-line sign-in is enabled by default. thunderbolt login shows a code, the user approves it in the app, and the CLI receives a session. Registering the CLI as a visible, revocable device requires CLI_DEVICE_REGISTRATION_ENABLED=true. Two settings tune the grant: DEVICE_AUTH_EXPIRES_IN (default 30m) is how long an unapproved code stays valid, and DEVICE_AUTH_INTERVAL (default 5s) the minimum polling gap.
Personal access tokens, the long-lived tokens users create for scripts and automation, are enabled by default too. A token is shown once at creation and lasts 90 days, or whatever API_KEY_DEFAULT_EXPIRES_IN (in seconds) says. Confidential models refuse a token unless CONFIDENTIAL_API_KEYS_ENABLED=true.
Anonymous sessions ship disabled, and an SSO-built app never offers them. The server setting is mode-independent, though: AUTH_ALLOW_ANONYMOUS=true mounts the anonymous sign-in endpoint whatever AUTH_MODE is, so leave it off on an SSO deployment. Turning them on takes AUTH_ALLOW_ANONYMOUS=true on the server, which lets visitors try the app with no account, plus an app built with VITE_AUTH_ENABLE_ANONYMOUS=true and VITE_BYPASS_WAITLIST=true. With the server setting alone, visitors still meet the sign-in wall; with the build flags alone, they get a button the server has no endpoint for.
Sessions and devices
A session belongs to the device that created it. With sync on, users see every signed-in device under Settings, Devices, and can revoke any of them. Without sync a device only ever sees itself, so revoking a lost one needs sync. Revoking ends that device’s sessions and stops it syncing, and the revoked device is told why the next time it reaches the server.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The app loads normally instead of redirecting to your provider | The app was built without VITE_AUTH_MODE=sso, or a stale session exists |
Rebuild the app image, or clear site data in the browser and reload |
| An untrusted-origin error during sign-in | Your provider’s origin is missing from TRUSTED_ORIGINS |
Add it. Container deployments usually need both the public and the internal origin. |
| A discovery error during sign-in | The provider is unreachable from the server | Check the provider is running and set OIDC_DISCOVERY_URL to an address the server can reach |
| The server will not start, naming an OIDC or SAML variable | A setting the chosen mode requires is missing or misspelled | Set every variable listed for that mode above |
| The callback returns 404 | The redirect URI registered with the provider does not match | Register <BETTER_AUTH_URL>/v1/api/auth/sso/callback/sso exactly |
| SAML rejects the assertion | The wrong assertion consumer URL, or a mismatched entity ID | Compare the provider’s configuration against the service-provider metadata URL above |
| An invalid certificate error on SAML | The certificate still has its PEM header and footer | Use the raw base64 body only |
| Nobody receives a sign-in code | No email service is configured | Check the server logs |
| A new user gets a waitlist email instead of a code | The address has no account and is not approved | Add its domain to WAITLIST_AUTO_APPROVE_DOMAINS and restart, or approve its waitlist row |
Next
- Configuration: the full settings reference, including everything on this page.
- Docker Compose, Kubernetes, AWS with Pulumi: where to put these settings for each deployment path.