This guide covers running and testing the SAML 2.0 authentication flow locally. This is an auth mode for enterprise self-hosted deployments where users sign in through their organization's SAML identity provider (Keycloak, Okta, Microsoft Entra ID, etc.).
For OIDC authentication, see oidc-local-dev.md.
In SAML mode (AUTH_MODE=saml), the app has no login page. Unauthenticated users are immediately redirected through a chain:
- App detects no session, redirects to backend's SSO sign-in endpoint
- Backend generates a SAML AuthnRequest and redirects to the IdP's SSO URL
- User authenticates with their identity provider (corporate SSO)
- IdP POSTs a signed SAML assertion back to the backend's ACS endpoint
- Backend validates the assertion, creates/updates user + session
- Backend redirects to frontend — user is authenticated
Any SAML 2.0-compliant provider works. The implementation uses the @better-auth/sso plugin with the samlify library.
The docs/mozilla-realm.json file contains a ready-to-go realm with both OIDC and SAML clients plus test users. Mount it on startup so there's zero manual setup:
cd backend # run from backend/ so the volume mount path resolves correctly
docker run -d \
--name keycloak \
-p 8180:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v $(pwd)/docs/mozilla-realm.json:/opt/keycloak/data/import/mozilla-realm.json \
quay.io/keycloak/keycloak:latest \
start-dev --import-realmThis creates:
- Realm:
mozilla - OIDC client:
thunderbolt-app(for OIDC mode) - SAML client:
thunderbolt-saml-sp(for SAML mode) - Users:
mitchell@mozilla.org/password,laura@mozilla.org/password
Keycloak admin panel is at http://localhost:8180 (login: admin / admin).
The SAML flow requires the IdP's signing certificate to validate assertions. Extract it from Keycloak's SAML descriptor:
# Option A: with xmllint (if installed)
curl -s http://localhost:8180/realms/mozilla/protocol/saml/descriptor \
| xmllint --xpath '//ds:X509Certificate/text()' \
--namespace ds=http://www.w3.org/2000/09/xmldsig# -
# Option B: with grep (works everywhere)
curl -s http://localhost:8180/realms/mozilla/protocol/saml/descriptor \
| sed -n 's/.*<ds:X509Certificate>\(.*\)<\/ds:X509Certificate>.*/\1/p' | head -1
# Option C: Keycloak admin UI -> Realm Settings -> Keys -> RSA certificate -> copyCopy the certificate value (base64 string, no BEGIN/END markers).
Backend (backend/.env):
AUTH_MODE=saml
WAITLIST_ENABLED=false
SAML_ENTRY_POINT=http://localhost:8180/realms/mozilla/protocol/saml
SAML_ENTITY_ID=thunderbolt-saml-sp
SAML_IDP_ISSUER=http://localhost:8180/realms/mozilla
SAML_CERT=<paste-certificate-from-step-2>
# Include the IdP origin in trusted origins
TRUSTED_ORIGINS=http://localhost:1420,http://localhost:8180Frontend (.env.local in project root):
VITE_AUTH_MODE=sso
# Make sure VITE_BYPASS_WAITLIST is NOT set (or set to false) — it skips the auth gate entirely# Terminal 1 — backend
cd backend && bun dev
# Terminal 2 — frontend
bun devOpen http://localhost:1420 — you should be redirected to Keycloak's login page for the "mozilla" realm. Sign in as mitchell@mozilla.org / password.
The backend exposes Service Provider metadata at:
http://localhost:8000/v1/api/auth/sso/saml2/sp/metadata?providerId=sso
Enterprise admins can use this to configure their IdP.
The implementation is provider-agnostic. To use Okta, Entra ID, or any other SAML 2.0 provider, set the three env vars:
SAML_ENTRY_POINT=https://your-idp.example.com/sso/saml
SAML_ENTITY_ID=https://your-idp.example.com
SAML_CERT=<idp-signing-certificate-base64>You'll need to register the ACS URL with the provider:
https://<your-backend>/v1/api/auth/sso/saml2/sp/acs/sso
| Symptom | Cause | Fix |
|---|---|---|
| App loads normally, no redirect to IdP | VITE_BYPASS_WAITLIST is set to true |
Remove it or set to false, restart frontend |
| App loads normally, no redirect to IdP | Stale auth session from a previous login | Clear site data (DevTools → Application → Storage → Clear site data) |
discovery_untrusted_origin error |
IdP origin not in TRUSTED_ORIGINS |
Add http://localhost:8180 to TRUSTED_ORIGINS in backend/.env |
| Keycloak not reachable | Container not running | Run docker ps | grep keycloak and start it if needed |
| SAML ACS returns error | Wrong ACS URL in Keycloak SAML client | Ensure saml_assertion_consumer_url_post matches /v1/api/auth/sso/saml2/sp/acs/sso |
| Invalid certificate error | Certificate has PEM headers or wrong format | Use the raw base64 string without -----BEGIN CERTIFICATE----- / -----END CERTIFICATE----- |
Most SAML providers maintain their own session. Logging out of Thunderbolt alone won't clear the provider session — the user will be silently re-authenticated on the next visit. This is expected SSO behavior.
| File | Purpose |
|---|---|
backend/src/auth/auth.ts |
Conditionally adds @better-auth/sso plugin when AUTH_MODE=saml |
backend/src/config/settings.ts |
authMode, samlEntryPoint, samlIssuer, samlCert env vars |
backend/src/auth/saml-integration.test.ts |
SAML integration tests |
backend/docs/mozilla-realm.json |
Pre-configured Keycloak realm with SAML client |
src/lib/auth-mode.ts |
isSsoMode() — reads VITE_AUTH_MODE |
src/app.tsx |
SsoRedirect component, conditional routing for SSO vs consumer mode |
src/contexts/auth-context.tsx |
credentials: 'include' in SSO mode for cookie-based session bootstrap |