Appearance
OAuth / SSO
Postmill supports generic OpenID Connect (OIDC) providers for user authentication. This lets self-hosters integrate with Authentik, Keycloak, Authelia, Dex, Supabase, or any OIDC-compliant identity provider. SAML is not supported — OIDC only.
Admin-managed login providers
Login providers are configured in the separate administration app (a distinct repository — this repo ships no /admin frontend and no login-provider write API), stored platform-wide in the AuthProviderConfig table with client ID/secret encrypted at rest (one config per provider: Google, GitHub, or generic OIDC). For each provider you can set:
- Enabled — whether the provider's button appears on the login page
- Client ID / Client Secret — encrypted; never returned to the browser after save
- OIDC endpoints (generic provider only) — authorization, token, and userinfo URLs
- Scopes — defaults to
openid profile email - Display name — the login button label (e.g. "Sign in with Supabase")
This repo only reads AuthProviderConfig. The login page reads GET /auth/providers, which merges the env-derived provider list with the enabled DB configs: email/password (LOCAL) is always present, env-configured providers stay listed, and a DB config wins per provider key (its display name and credentials take precedence over the env ones for that provider only).
Env fallback (the chicken-and-egg): when no enabled DB config exists for a provider, the env vars below are used instead — so a fresh deployment can offer SSO logins before anything is configured in the database. Email/password (LOCAL) login is always available regardless of provider config (subject to DISABLE_REGISTRATION).
OAuth logins also import the user's display name and avatar from the provider; accounts without a provider picture fall back to a Gravatar generated from their email, then to initials.
Generic OIDC setup
1. Create an OIDC application in your provider
Create a new OIDC client in your identity provider. You will need:
- Client ID and Client Secret — generated by your provider
- Redirect URI —
https://<your-postmill-domain>/settings - Scopes — typically
openid profile email
Example redirect URI: https://postmill.example.com/settings
2. Configure the provider
Preferred: in the administration app, enable the Generic OIDC provider and enter the client ID/secret, the three endpoints, scopes, and a display name. No redeploy needed.
Or via env vars (bootstrap fallback) — set these in your .env file or Docker Compose environment; they apply only while no enabled DB config exists for the provider:
yaml
# Required for any self-hosted deployment
IS_GENERAL: 'true'
# Enable generic OIDC
POSTMILL_GENERIC_OAUTH: 'true'
# Provider endpoints
POSTMILL_OAUTH_AUTH_URL: 'https://auth.example.com/application/o/authorize'
POSTMILL_OAUTH_TOKEN_URL: 'https://auth.example.com/application/o/token'
POSTMILL_OAUTH_USERINFO_URL: 'https://auth.example.com/application/o/userinfo'
# Client credentials
POSTMILL_OAUTH_CLIENT_ID: 'your-client-id'
POSTMILL_OAUTH_CLIENT_SECRET: 'your-client-secret'
# Login button appearance
NEXT_PUBLIC_POSTMILL_OAUTH_DISPLAY_NAME: 'Authentik'
NEXT_PUBLIC_POSTMILL_OAUTH_LOGO_URL: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/master/png/authentik.png'3. Optional: Override OIDC scopes
The default scopes are openid profile email. Override if your provider requires different or additional scopes:
yaml
POSTMILL_OAUTH_SCOPE: 'openid profile email groups'4. Redeploy
Restart the application after setting these variables. The login page will show a button with NEXT_PUBLIC_POSTMILL_OAUTH_DISPLAY_NAME and NEXT_PUBLIC_POSTMILL_OAUTH_LOGO_URL.
Required env vars
| Variable | Default | Purpose |
|---|---|---|
IS_GENERAL | — | Must be true for self-hosted deployments |
POSTMILL_GENERIC_OAUTH | false | Set to true to enable generic OIDC login |
POSTMILL_OAUTH_AUTH_URL | — | Authorization endpoint |
POSTMILL_OAUTH_TOKEN_URL | — | Token endpoint |
POSTMILL_OAUTH_USERINFO_URL | — | Userinfo endpoint |
POSTMILL_OAUTH_CLIENT_ID | — | Client ID from your OIDC provider |
POSTMILL_OAUTH_CLIENT_SECRET | — | Client secret from your OIDC provider |
NEXT_PUBLIC_POSTMILL_OAUTH_DISPLAY_NAME | — | Login button label |
NEXT_PUBLIC_POSTMILL_OAUTH_LOGO_URL | — | Login button icon URL |
Optional env vars
| Variable | Default | Purpose |
|---|---|---|
POSTMILL_OAUTH_SCOPE | openid profile email | OIDC scopes to request |
Provider-specific examples
Authentik
yaml
IS_GENERAL: 'true'
POSTMILL_GENERIC_OAUTH: 'true'
POSTMILL_OAUTH_AUTH_URL: 'https://authentik.example.com/application/o/authorize/'
POSTMILL_OAUTH_TOKEN_URL: 'https://authentik.example.com/application/o/token/'
POSTMILL_OAUTH_USERINFO_URL: 'https://authentik.example.com/application/o/userinfo/'
POSTMILL_OAUTH_CLIENT_ID: '<your-client-id>'
POSTMILL_OAUTH_CLIENT_SECRET: '<your-client-secret>'
NEXT_PUBLIC_POSTMILL_OAUTH_DISPLAY_NAME: 'Authentik'
NEXT_PUBLIC_POSTMILL_OAUTH_LOGO_URL: 'https://raw.githubusercontent.com/walkxcode/dashboard-icons/master/png/authentik.png'In Authentik:
- Create a new Application with type
OAuth2/OpenID - Set the redirect URI to
https://<your-domain>/settings - Under the Provider settings, note the client ID and secret
- Under Advanced protocol settings, ensure the signing key and subject mode are set
Keycloak
yaml
POSTMILL_OAUTH_AUTH_URL: 'https://keycloak.example.com/realms/<realm>/protocol/openid-connect/auth'
POSTMILL_OAUTH_TOKEN_URL: 'https://keycloak.example.com/realms/<realm>/protocol/openid-connect/token'
POSTMILL_OAUTH_USERINFO_URL: 'https://keycloak.example.com/realms/<realm>/protocol/openid-connect/userinfo'Authelia
yaml
POSTMILL_OAUTH_AUTH_URL: 'https://auth.example.com/api/oidc/authorization'
POSTMILL_OAUTH_TOKEN_URL: 'https://auth.example.com/api/oidc/token'
POSTMILL_OAUTH_USERINFO_URL: 'https://auth.example.com/api/oidc/userinfo'Dex
yaml
POSTMILL_OAUTH_AUTH_URL: 'https://dex.example.com/auth'
POSTMILL_OAUTH_TOKEN_URL: 'https://dex.example.com/token'
POSTMILL_OAUTH_USERINFO_URL: 'https://dex.example.com/userinfo'GitHub login
In addition to generic OIDC, Postmill supports GitHub OAuth as a login provider. Configure it in the administration app (preferred), or via the bootstrap env vars:
yaml
GITHUB_CLIENT_ID: '<your-github-oauth-app-client-id>'
GITHUB_CLIENT_SECRET: '<your-github-oauth-app-client-secret>'Create a GitHub OAuth App at Settings -> Developer settings -> OAuth Apps with the callback URL set to https://<your-domain>/settings (the adapter requests the user:email scope).
Google login
Google OAuth login is also supported. Configure it in the administration app (preferred), or use YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET as the bootstrap env credentials. These variables are dual-use: they are also the platform channel app for YouTube posting — setting them gives every organization one-click YouTube Connect (per-org Google/YouTube/GMB apps via Settings → Channels still win for channel posting). Setup details: Platform Channel Apps → Google — YouTube.
Facebook login
Facebook OAuth login reuses the platform channel app — there is no separate login credential set. Set the flag and the channel credentials:
yaml
FACEBOOK_SSO_ENABLED: 'true'
FACEBOOK_APP_ID: '<your-meta-app-id>'
FACEBOOK_APP_SECRET: '<your-meta-app-secret>'Callback to register: https://<your-domain>/integrations/social/facebook. Which dialog Postmill opens depends on the Meta app type:
Consumer-type app with classic Facebook Login — the login requests
public_profile,email; nothing else to configure.Business-type app on Facebook Login for Business (the kind the channel setup in Platform Channel Apps produces) — Meta rejects a plain permission list for these apps ("It looks like this app isn't available. This app needs at least one supported permission."); the dialog must reference a Configuration instead, and
email/public_profileare granted automatically on top of it. Create a login-only Configuration: Facebook Login for Business → Configurations → Create, login variation User access token, add the smallest business permission the app already holds (for examplepages_show_list), save, copy its ID:yamlFACEBOOK_SSO_CONFIG_ID: '<login-configuration-id>'If
FACEBOOK_SSO_CONFIG_IDis unset Postmill falls back to the channelFACEBOOK_CONFIG_ID— sign-in then works, but the consent screen also asks the user to grant Pages access. A Configuration belongs to one Meta app, so it is only applied to the env app (admin-app credentials keep the classic dialog).
If the user denies the email permission, Postmill mints a synthetic address (fb_<id>@facebook.login.postmill.local) that is skipped by newsletter/welcome emails.
X login
X OAuth login runs on the same X developer app as channel posting, but over a separate OAuth 2.0 + PKCE flow with its own credentials. Channel posting is OAuth 1.0a and uses the consumer API Key / API Secret (X_API_KEY / X_API_SECRET); X does not accept that consumer key as an OAuth 2.0 client_id. For login:
- In the X developer portal open the app → User authentication settings and enable OAuth 2.0 (keep OAuth 1.0a on for posting). Type of App: Web App. Add
https://<your-domain>/integrations/social/xto the Callback URIs. Under App permissions tick Request email from users (X requires a Terms of Service URL and a Privacy Policy URL on the app for that) so logins can carry the real address. - Copy the OAuth 2.0 Client ID and Client Secret from Keys and tokens (they look like
xxxxxxxx:1:ci/ a long secret — generated only once OAuth 2.0 is enabled). - Set:
yaml
X_SSO_ENABLED: 'true'
X_CLIENT_ID: '<oauth-2.0-client-id>'
X_CLIENT_SECRET: '<oauth-2.0-client-secret>'X_API_KEY / X_API_SECRET stay as they are — channel connections and posting are unaffected by the login flow.
Each login attempt gets its own PKCE verifier, bound to a nonce inside the OAuth state (state=login.<nonce>, Redis-backed, 10-minute TTL), so concurrent logins never collide. Login requests the tweet.read users.read users.email scopes: X's /2/users/me needs the first two (with users.read alone the profile lookup is refused), and users.email returns the user's confirmed_email when the app has Request email from users enabled. Without it the account gets a synthetic address (x_<id>@x.login.postmill.local), skipped by newsletter/welcome emails. A failed exchange or profile lookup surfaces X's own error text on the login page.
LinkedIn login
LinkedIn OAuth login reuses the platform channel app via LinkedIn's OpenID Connect flow (openid profile email against /v2/userinfo):
yaml
LINKEDIN_SSO_ENABLED: 'true'
LINKEDIN_CLIENT_ID: '<your-client-id>'
LINKEDIN_CLIENT_SECRET: '<your-client-secret>'The app needs the Sign In with LinkedIn using OpenID Connect product enabled (the channel flow already requires it). Callback to register: https://<your-domain>/integrations/social/linkedin.
No account linking (all SSO providers): logins match users by (provider, provider-user-id) only. The same person signing in via two providers — even with the same real email — gets separate User + Org accounts; identities are never merged.
Apple login
Sign in with Apple is a standalone, login-only provider (it shares nothing with a channel app). Unlike every other provider it has no static client secret — Postmill mints a short-lived ES256 JWT from your Team ID, Key ID, and .p8 private key on every token exchange.
1. Create an App ID
In the Apple Developer portal (Certificates, Identifiers & Profiles → Identifiers), create an App ID (e.g. ai.postmill.app) and enable the Sign In with Apple capability on it.
2. Create a Services ID
Create a second identifier of type Services ID — this is your APPLE_CLIENT_ID (e.g. ai.postmill.app.auth). Enable Sign In with Apple on it, click Configure, set the primary App ID to the one from step 1, and register the return URL:
https://<your-domain>/auth/callback/apple3. Create a Sign in with Apple key
Under Keys, create a new key with Sign in with Apple enabled (configure it against the primary App ID) and download the .p8 file — Apple only offers the download once. Note the Key ID shown next to it, and your Team ID (top-right of the portal).
4. Configure Postmill
Base64-encode the .p8 contents (so the multi-line PEM survives .env/Compose round-trips):
bash
base64 -w0 AuthKey_XXXXXXXXXX.p8Then set:
yaml
APPLE_SSO_ENABLED: 'true'
APPLE_CLIENT_ID: '<your-services-id>' # e.g. ai.postmill.app.auth
APPLE_TEAM_ID: '<your-team-id>'
APPLE_KEY_ID: '<your-key-id>'
APPLE_PRIVATE_KEY: '<base64-encoded .p8 contents>'As with the other providers, an enabled APPLE row in the administration app's AuthProviderConfig table takes precedence (client ID = Services ID, client secret = the base64 .p8 key); the Team ID and Key ID always come from env.
Two Apple quirks to be aware of:
- The callback is a form POST. Requesting the
emailscope forcesresponse_mode=form_post; a frontend route handler converts the POST into the standard/auth?code=…&state=login&provider=APPLEredirect. Ensure your reverse proxy forwards POSTs to/auth/callback/appleunchanged. - The email can be missing. Apple only sends the email claim on the user's first consent, and users can hide it behind Apple's private relay. When no email arrives, Postmill does not mint a synthetic address (unlike X/Facebook) — the registration form re-prompts the user for an email address instead.
Callback to register: https://<your-domain>/auth/callback/apple.
Login callback routes
OAuth callbacks do not land on a single /auth/callback endpoint. Each login provider redirects back to its own path carrying state=login (X: state=login.<nonce>), and the frontend proxy (apps/frontend/src/proxy.ts) forwards it to /auth?…&provider=<NAME>:
| Provider | Callback path to register |
|---|---|
https://<your-domain>/integrations/social/youtube?state=login (register the path without the query) | |
https://<your-domain>/integrations/social/facebook | |
| X | https://<your-domain>/integrations/social/x |
https://<your-domain>/integrations/social/linkedin | |
| GitHub | https://<your-domain>/settings |
| Generic OIDC | https://<your-domain>/settings |
| Apple | https://<your-domain>/auth/callback/apple (Apple POSTs a form body here; a frontend route handler converts it to the /auth?…&provider=APPLE redirect) |
The dual-use paths are shared with channel OAuth: channel-connect callbacks hit the same /integrations/social/<provider> paths without state=login and pass straight through to the channel flow. Ensure your reverse proxy passes these paths (and /settings) through to the frontend without modification.
Popup flow
The login and register pages open every OAuth provider in a popup window (640×720, named postmill-sso), the same way Settings → Channels connects a channel: the page behind it waits, the popup runs the provider's consent screen and the /auth?…&provider= callback, and once the backend has set the auth cookie the popup signals the opener and closes itself; the opener then reloads (existing user) or moves to /dashboard (new user, after the Company form — which also renders inside the popup). Operational notes:
- The completion signal is written to
localStorage(postmill:sso-complete) with apostMessagefast path. Some consent pages sendCross-Origin-Opener-Policy(x.com:same-origin-allow-popups), which severswindow.opener— the storage signal survives that, so the opener keeps listening for up to 10 minutes rather than treating a "closed" handle as the end of the flow. - If the browser blocks the popup, the button falls back to the classic full-page redirect.
- Nothing changes on the provider side: register the same callback paths listed above.
Postmill ID (this instance as an identity provider)
The reverse direction — first-party apps such as the template store signing users in with this Postmill instance ("Sign in with Postmill") — needs no setup: there is no client registration, and the instance generates its own signing key on first use. FRONTEND_URL must be set, and FEDERATION_TRUSTED_REDIRECT_URIS / FEDERATION_ISSUER override the defaults (see Configuration → Postmill ID federation). Users review and revoke these sign-ins on the approved-apps page.
Disable registration
To restrict who can sign up, set DISABLE_REGISTRATION=true. New users cannot self-register via LOCAL (email/password) or any OAuth provider (Google, GitHub, Facebook, X, LinkedIn, Apple) — with two exceptions: the very first user of an empty instance can always register (so the operator can bootstrap), and GENERIC OIDC sign-ins are exempt (users authenticated by your identity provider still provision). Existing users can still log in. Create new users through the Teams settings page.
Related
- Configuration — full env var reference including
IS_GENERAL, GitHub, and SSO vars - Platform Channel Apps — the shared channel-OAuth apps the Facebook/X/LinkedIn/Google logins dual-use
- Security — OAuth 2.0 / PKCE hardening, open-redirect allowlisting
Verified against v1.3.0