Bulwark only. This page applies to Bulwark and has no counterpart in Bulwark Lite.

Embedded SSO

Run Bulwark inside an iframe in a parent portal that already handles authentication through SSO.

Not in Bulwark Lite: OAuth and SSO are pinned off in the static export, which has no server to hold the PKCE state or the cookies. Lite's config.json does accept embeddedMode and parentOrigin for framing a password login, but the SSO flow on this page needs the full edition.

Prerequisites

Embedded SSO needs all three of these in place first:

  • OAuth 2.0 / OIDC configured (see Authentication)
  • A SESSION_SECRET set for server-side PKCE encryption
  • HTTPS enabled (required for cross-origin cookies)

Configuration

Add the following environment variables to your deployment:

# Required: OAuth must be enabled in SSO-only mode
OAUTH_ENABLED=true
OAUTH_ONLY=true
OAUTH_CLIENT_ID=webmail
OAUTH_ISSUER_URL=https://auth.example.com

# Required: encryption key for server-side PKCE state
SESSION_SECRET=your-32-char-secret-here

# Enable automatic SSO (skips the login form)
AUTO_SSO_ENABLED=true

# Allow iframe embedding from your portal's origin
ALLOWED_FRAME_ANCESTORS=https://portal.example.com

# Cross-origin cookies (required when embedded cross-origin)
COOKIE_SAME_SITE=none

# Parent origin for postMessage validation
NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com
VariableDescriptionDefault
AUTO_SSO_ENABLEDAutomatically start the OAuth flow on the login pagefalse
ALLOWED_FRAME_ANCESTORSCSP frame-ancestors value'none'
COOKIE_SAME_SITECookie SameSite attribute (lax, none, strict)lax
NEXT_PUBLIC_PARENT_ORIGINOrigin of the parent frame for postMessage validation(empty)

Example Docker Compose file

services:
  webmail:
    image: bulwark-webmail
    environment:
      - JMAP_SERVER_URL=https://mail.example.com
      - OAUTH_ENABLED=true
      - OAUTH_ONLY=true
      - OAUTH_CLIENT_ID=webmail
      - OAUTH_ISSUER_URL=https://auth.example.com
      - SESSION_SECRET=your-32-char-secret-here
      - AUTO_SSO_ENABLED=true
      - ALLOWED_FRAME_ANCESTORS=https://portal.example.com
      - COOKIE_SAME_SITE=none
      - NEXT_PUBLIC_PARENT_ORIGIN=https://portal.example.com

How it works

Server-side PKCE flow

The usual OAuth PKCE flow keeps the code_verifier in sessionStorage, and that is lost the moment the browser crosses browsing contexts, iframe to top-level and back. Bulwark keeps the PKCE state in an encrypted httpOnly cookie instead:

  1. Start - The client calls POST /api/auth/sso/start. The server generates PKCE parameters, stores them in an encrypted cookie (sso_pending with a 5-minute TTL), and returns the OAuth authorize_url.

  2. Redirect - The browser redirects to the OAuth provider's authorize endpoint.

  3. Callback - The OAuth provider redirects back to /auth/callback with code and state parameters.

  4. Complete - The client calls POST /api/auth/sso/complete. The server reads the encrypted cookie, validates the state and TTL, exchanges the authorization code for tokens using the stored code_verifier, sets the refresh_token cookie, and returns the access_token.

Auto-SSO

When AUTO_SSO_ENABLED=true and OAUTH_ONLY=true, the login page automatically starts the server-side SSO flow without user interaction. A 30-second loop guard prevents infinite redirect loops if the OAuth flow fails.

Non-interactive SSO

Where no login UI should ever appear, the OAuth flow starts and finishes without user interaction. This is the mode to use when a parent portal owns the whole login experience.

iframe security

When ALLOWED_FRAME_ANCESTORS is set to anything other than 'none':

  • The X-Frame-Options: DENY header is removed
  • CSP frame-ancestors is set to the configured value
  • Set COOKIE_SAME_SITE=none for cross-origin iframe cookies (requires HTTPS)

postMessage bridge

Bulwark communicates with the parent frame via postMessage. All outgoing messages include source: 'bulwark'.

Outgoing messages, webmail to parent

TypePayloadWhen
sso:auth-success{ username }User successfully authenticated
sso:auth-failure{ error }Authentication failed
sso:logout-User logged out
sso:session-expired-Token refresh failed

Incoming messages, parent to webmail

Messages must include source: 'portal'. If NEXT_PUBLIC_PARENT_ORIGIN is set, only messages from that origin are accepted.

TypeEffect
sso:trigger-loginNavigates to the login page (starts auto-SSO if enabled)
sso:trigger-logoutLogs the user out

Example parent integration

const iframe = document.getElementById("webmail-iframe");

// Listen for auth events from Bulwark
window.addEventListener("message", (event) => {
  if (event.data?.source !== "bulwark") return;

  switch (event.data.type) {
    case "sso:auth-success":
      console.log("User logged in:", event.data.username);
      break;
    case "sso:session-expired":
      // Re-trigger login when session expires
      iframe.contentWindow.postMessage(
        { source: "portal", type: "sso:trigger-login" },
        "https://webmail.example.com",
      );
      break;
  }
});

// Trigger logout from the parent portal
function logoutWebmail() {
  iframe.contentWindow.postMessage(
    { source: "portal", type: "sso:trigger-logout" },
    "https://webmail.example.com",
  );
}

API reference

POST /api/auth/sso/start

Initiates the server-side OAuth flow.

Request:

{
  "redirect_uri": "https://webmail.example.com/en/auth/callback"
}

Response:

{
  "authorize_url": "https://auth.example.com/authorize?..."
}

POST /api/auth/sso/complete

Completes the server-side OAuth flow. Requires the sso_pending cookie set by the start endpoint.

Request:

{
  "code": "authorization_code_here",
  "state": "state_value_here"
}

Response:

{
  "access_token": "...",
  "expires_in": 3600
}