Keycloak authentication behind Awwwth
Keycloak handles credentials, email verification, MFA, passkeys and browser sign-in. Awwwth remains your public auth layer: account profiles, companies, local permissions, sessions, application registration, consent, signed app tokens and logout delivery. Existing applications continue to use the Awwwth issuer and SDK.
The sign-in flow is:
Application → Awwwth → Keycloak sign-in → Awwwth callback → application
Awwwth uses a confidential Keycloak client with authorization-code flow and S256 PKCE. It validates signed RS256 ID tokens, issuer, audience, authorized party, nonce, expiry and verified email before creating its own HttpOnly session. Keycloak tokens and PKCE verifiers are encrypted in PostgreSQL. The browser uses Keycloak's flow cookies and Awwwth's HttpOnly session cookie; upstream tokens stay on the server.
Branded sign-in and account settings stay on the Awwwth domain. Its server forwards only /realms/ and /resources/ to the private Keycloak backend, preserves Keycloak's form security policy and strips Awwwth session cookies. Administrator and management endpoints are not forwarded. Keycloak owns the credential forms, so its standard MFA and passkey flows remain available. See Keycloak reverse proxy configuration.
Start the local stack
Install Docker with Compose, then run from the repository root:
mkdir -p .local
cp deploy/keycloak/keycloak.env.example .local/keycloak.env
chmod 600 .local/keycloak.env
Edit .local/keycloak.env. Set KEYCLOAK_ADMIN_PASSWORD to a private password and KEYCLOAK_CLIENT_SECRET to a separate random secret. Generate the client secret with openssl rand -hex 32, then paste it into the private file. Both secrets are required; .local is ignored by Git.
docker compose --env-file .local/keycloak.env -f compose.yml -f compose.keycloak.yml up -d --build
docker compose --env-file .local/keycloak.env -f compose.yml -f compose.keycloak.yml ps
Open Awwwth at <http://localhost:4100> and the Keycloak admin console at <http://localhost:8080/admin/>. Sign into the admin console with the bootstrap credentials, select techinject, then create a user with an email address. Under Credentials, set a password. For a local test, mark Email verified on the user before signing into Awwwth.
The awwwth-broker client is imported with the supplied secret, exact callback URL, S256 PKCE, RS256 ID tokens, and back-channel logout. The included awwwth login theme extends Keycloak's standard forms with Awwwth's monochrome colors, star mark and Geist font. Users sign in at http://localhost:4100/realms/techinject/... and use account settings on the same domain. See the Keycloak theme guide for further customization.
An internal Nginx proxy forwards only signed back-channel logout requests with the configured Awwwth Host. It publishes no host port and leaves the app's Host and token validation in place. Its pinned image comes from the official Nginx image registry.
Public registration and password recovery are disabled initially. Configure SMTP in the realm before enabling registration, required email verification and password recovery. Awwwth rejects unverified identities. Keycloak controls the MFA and passkey policies; configure them in the realm and manage personal credentials in its account console.
This overlay must be combined with compose.yml; it starts the whole application stack. Keycloak uses a separate persistent development data volume and binds only to loopback. Development mode uses an embedded database and is for local use. The pinned version follows the official Docker quick start.
Connect an existing Keycloak server
Register a confidential OpenID Connect client in your realm:
| Setting | Value |
|---|---|
| Client ID | awwwth-broker (or your chosen name) |
| Client authentication | On |
| Standard flow | On |
| Direct access grants / implicit flow / service accounts | Off |
| Default client scopes | basic, profile, email |
| Full scope allowed | Off |
| Dedicated Audience mapper | Included Client Audience: awwwth-broker; add to access token and token introspection; exclude from ID token |
| PKCE method | S256 |
| ID token signature algorithm | RS256 |
| Valid redirect URI | https://auth.example.com/auth/keycloak/callback |
| Valid post logout redirect URI | https://auth.example.com/ |
| Back-channel logout URL | https://auth.example.com/auth/keycloak/backchannel-logout |
| Back-channel logout session required | On |
Replace https://auth.example.com with AUTH_ORIGIN. For back-channel logout, use a reachable URL whose reverse proxy forwards the configured Awwwth Host. Configure these values in the server environment:
Keep the basic scope attached: the pinned Keycloak version maps auth_time through that scope, and Awwwth requires it for session freshness. See Keycloak's built-in scope implementation. Older or customized realms must provide the same signed auth_time claim in ID tokens.
Keep the dedicated Audience mapper attached as well. Its Included Client Audience must match your broker client ID if you use a different name. Keycloak 26.8 requires the introspecting client in the access token audience; omitting it causes active sign-ins to fail the session check. The imported client includes this mapping with full scope disabled. See Keycloak's audience validation.
AUTH_BACKEND=keycloak
KEYCLOAK_ISSUER=https://auth.example.com/realms/techinject
KEYCLOAK_CLIENT_ID=awwwth-broker
KEYCLOAK_CLIENT_SECRET=your-private-client-secret
# Required separate private realm URL for same-domain sign-in.
KEYCLOAK_BACKCHANNEL_URL=https://keycloak.internal.example.com/realms/techinject
For same-domain sign-in, configure the Keycloak server as follows:
KC_HOSTNAME=https://auth.example.com
KC_HOSTNAME_ADMIN=https://keycloak-admin.internal.example.com
KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
KC_PROXY_HEADERS=xforwarded
Keep the Keycloak backend and administrator origin private; allow forwarded headers only from trusted proxies. The administrator hostname alone does not restrict access. Your existing Awwwth reverse proxy can forward all requests to Bun; Awwwth routes the user-facing Keycloak paths internally. See Keycloak hostname settings.
The public issuer and back-channel URL must be canonical realm URLs with no trailing slash, credentials, query or fragment. Production requires HTTPS on both. The private URL is used for token exchange, introspection, signing keys, forms and resources; ID-token issuer validation always uses the public issuer.
An existing Keycloak installation on a separate public SSO domain is also supported: use that domain in KEYCLOAK_ISSUER. In that mode, forms stay on the SSO domain and the private back-channel URL is optional. See Keycloak's OIDC endpoints.
Awwwth still needs PostgreSQL, ENCRYPTION_KEY, SIGNING_KEY_PATH, and the rest of its server configuration. Run bun run migrate before restarting it. The OCI override maps these variables from oci.env when deploying against an external production Keycloak server. Keep development Keycloak Compose settings out of that production stack.
Keep your application's integration
Applications still register in Awwwth and use AWWWTH_ISSUER=https://auth.example.com. Their client IDs and secrets remain Awwwth credentials. Only the Awwwth server knows the Keycloak broker secret.
Account screens open branded Keycloak forms on the Awwwth domain for sign-in or reauthentication. Password, verification, MFA and passkey controls open the same-domain secure account console. Profiles, addresses, company directories, app registration, local status and session controls remain in Awwwth.
The original inline password/signup/MFA APIs and embedded Awwwth passkeys are disabled in Keycloak mode. Apps using those features need the hosted sign-in flow or widget. Keycloak receives passwords through its own standard forms. The Awwwth proxy forwards those requests without implementing a separate credential API or storing their contents. Setting AUTH_BACKEND=native selects the original backend for deployments that still use it.
Existing users and company permissions
New verified Keycloak identities receive a local platform account with an empty password hash. The stable mapping is the exact (issuer, subject) pair, never email alone. Awwwth does not import Keycloak realm roles as local administrator permissions.
If a platform account already uses the same email, sign-in stops with account_link_required. A local operator must verify ownership of both accounts, then explicitly link the existing platform UUID to the Keycloak user's ID:
bun run keycloak:link <platform-user-uuid> <keycloak-user-id>
This command uses the configured database and realm. It preserves the account ID, company profiles, ownership and application permissions. It clears that platform account's old password, MFA and passkeys, ends its existing sessions and queues app logout. Configure new credentials in Keycloak first. It refuses disabled, unverified, company-only or already linked accounts. No migration is performed automatically.
When running inside the container, the startup process loads the encryption key into its own environment. Load it explicitly for this operator command as well:
docker compose --env-file .local/keycloak.env -f compose.yml -f compose.keycloak.yml exec app sh -c 'export ENCRYPTION_KEY="$(cat /app/.local/keys/encryption.key)"; exec bun run keycloak:link "$@"' sh <platform-user-uuid> <keycloak-user-id>
Legacy independent company accounts must first be explicitly linked to their shared platform identity while using the native backend. A Keycloak login cannot take over a company account with a matching email. Newly created company profiles share the authenticated platform identity and receive no application roles automatically, following the existing company registration policy.
Awwwth's local administrator command still grants local administration to an existing active platform account. Disabling or deleting a local profile blocks its Awwwth apps; it does not delete the upstream Keycloak user. Manage upstream account lifecycle in Keycloak. Changing an upstream user's email requires an operator to reconcile the local email; silent email-based remapping is rejected.
Sessions and logout
Every authenticated account request and app token check validates the upstream session through Keycloak introspection. Near token expiry, Awwwth refreshes under a PostgreSQL row lock so concurrent requests and multiple instances do not reuse a rotated refresh token. Provider outages fail closed with a retryable error, without deleting a valid local session.
Login callbacks and logout delivery share database advisory locks so a logout cannot miss a session that is still being created. Validation uses the same local-session-first lock order as reauthentication and revocation.
Signed back-channel logout events revoke all Awwwth sessions associated with the upstream session and queue logout for connected apps. Replay is idempotent. A normal sign-out revokes those local sessions immediately and then redirects to Keycloak's standard logout endpoint. Local device revocation ends that device's Awwwth app access; it does not delete the user's central Keycloak account.
Awwwth's five-minute reauthentication policy uses Keycloak's actual auth_time, rather than treating an existing SSO session as a fresh password check. Reauthentication is bound to the original local account and browser; switching users cannot confirm another account's security action. After returning from confirmation, repeat the requested action.
Verify and troubleshoot
bun run build
bun run test
bun run test:keycloak
bun run test:sso
The integration suites create disposable PostgreSQL clusters and require initdb and postgres on PATH. Keycloak tests use the same-domain proxy, a local RSA-signed OIDC fixture derived from the imported client scopes and audience mapper, and real Node gateways; they do not require Docker or connect to an existing database.
The pinned Keycloak 26.8.0 server was also checked with a disposable realm and database: same-domain discovery and branded forms, PKCE callback validation, introspection, refresh, account console, forced reauthentication, browser logout and signed back-channel delivery. The theme retains Keycloak's standard form markup; MFA/passkey enrollment depends on your realm policies and browser/authenticator support.
For the live stack:
curl --fail --silent --show-error http://localhost:4100/realms/techinject/.well-known/openid-configuration
docker compose --env-file .local/keycloak.env -f compose.yml -f compose.keycloak.yml logs --tail 100 keycloak app
The discovery issuer must be http://localhost:4100/realms/techinject. Sign-in forms and account settings must stay on port 4100; /admin/ must not be served there. Test Awwwth sign-in, connect two apps, then sign out and verify both apps lose access. If the realm already exists, startup import skips it: update the client secret, URLs, default scopes (basic, profile, email), dedicated Audience mapper and theme in the admin console. Changing an environment variable does not rotate an existing client or bootstrap administrator password. See realm import behavior.
For a host-run Bun server, use the separate private http://localhost:8080/realms/techinject as the back-channel URL, while the public issuer stays on port 4100. Configure a logout URL reachable from the Keycloak container through a reverse proxy that forwards Awwwth's configured Host. A plain host.docker.internal callback will be rejected by Awwwth's Host validation; use the complete Compose stack for the simplest local setup.
If the administrator port 8080 is occupied, update the host binding and KC_HOSTNAME_ADMIN; the container-internal back-channel URL stays unchanged. If the Awwwth port changes, update AUTH_ORIGIN, KC_HOSTNAME, KEYCLOAK_ISSUER, callback and post-logout URLs, and the internal logout proxy's Host together. If the imported secret no longer matches, token exchange fails; update it on both the client and Awwwth.
Stop the local stack while preserving data:
docker compose --env-file .local/keycloak.env -f compose.yml -f compose.keycloak.yml down
Production Keycloak needs HTTPS, a supported external database, backups, private secrets and production server settings. See the production guide. Existing Awwwth database and key backups remain necessary for your layer.