OIDC external hook
Custom OIDC authentication for n8n is implemented in external-hooks/src/api/hooks.ts and related helpers/routes.
This hook adds a single OIDC authorization flow that establishes an external UI session for every valid OIDC identity and an n8n session only after n8n eligibility succeeds.
Source layout
| Path | Role |
|---|---|
external-hooks/src/api/hooks.ts | Serves the OIDC frontend assets under /assets. |
external-hooks/src/api/routes/oidc.ts | Registers the sole OIDC login, callback, and logout routes (/rest/auth/oidc/*). |
external-hooks/src/api/services/oidc-login-coordinator.ts | Coordinates provider completion, token persistence, eligibility, provisioning, role sync, and both session artifacts. |
external-hooks/src/api/services/post-login-tenant.ts | Post-login tenant pre-warm and tenant-project sync for eligible users. |
external-hooks/src/api/helpers/oidc-provider.ts | OIDC discovery, PKCE, token exchange, ID-token verification, and identity extraction. |
external-hooks/src/api/helpers/n8n-oidc.ts | n8n-specific OIDC config, cookies, and role helpers. |
external-hooks/src/api/helpers/cookie.ts | Cookie Secure derivation from N8N_BASE_URL/N8N_PROTOCOL with production consistency checks. |
external-hooks/src/api/helpers/return-target.ts | Canonical returnTo policy shared by login, callback, and logout. |
external-hooks/src/api/assets/oidc-frontend-hook.js | Redirect-only browser script for n8n /login//signin → /ui and logout interception. |
external-hooks/src/api/utils/logger.ts | Structured request, response, and error logging helpers. |
Routes
1. Start login — GET /rest/auth/oidc/login
- If the request already has a valid
n8n-authcookie and no validatedreturnTo, redirects to/without contacting the provider. - If a validated
returnTo(/ui/*by policy) is supplied, always starts a fresh authorization even with an existing n8n session so the UI can obtain a new exchange handle. - Generates
stateandnonce, stores them in signedn8n-oidc-state/n8n-oidc-noncecookies (HttpOnly,SecurewhenN8N_BASE_URLishttps,SameSite=Lax,Path=/, 15 min), then redirects to the providerauthorization_endpoint(discovery or manual config).
2. Callback — GET /rest/auth/oidc/callback
Single browser callback. GET /ui-api/auth/callback and UI OIDC state records were removed; only this endpoint completes a new authorization.
Validates state and nonce cookies before token exchange, exchanges code for tokens, then:
- Requires an
id_tokenand a usablejwks_uri; cryptographically verifies signature viajose.jwtVerifywithissuer,audience,exp, andnonce. - Validates discovered
issuerexactly against configuredissuerUrl. - Requires
userinfo.subto exist and equal the verified ID-tokensub. - Merges claims authoritatively:
sub/iss/aud/nonce/exp/iatfrom ID token,email/rolesfrom ID token if present else verified userinfo, other claims supplement.
On success, the coordinator resolves eligibility and issues sessions (see § Authentication flow). On failure, redirects to /ui?error=<stable public message> with no cookies or handles.
3. Exchange — POST /ui-api/auth/exchange
Consumes a one-time UI exchange handle (see § Exchange handles) via JSON body {"session": "<handle>"} (authExchangeSchema) and returns the bearer token (access_token or app-issued JWT) to the SPA ({token} via authExchangeResponseSchema). The SPA stores it as Authorization: Bearer for ui-api calls and clears session/continue markers from history. Single-use via getDel (consumeUiSessionExchange); replay → 401.
4. Logout — GET /rest/auth/oidc/logout
Canonical logout. Accepts a validated returnTo (policy logout → /ui/*), an optional single-use logout handle binding an authenticated UI identity, and a validated n8n-auth cookie. See § Logout for ownership and revocation.
Compatibility aliases:
GET /ui-api/auth/login?returnTo=...→ 302 to/rest/auth/oidc/login?returnTo=...GET /ui-api/auth/logout?returnTo=...→ 302 to/rest/auth/oidc/logout?returnTo=...
Aliases are redirect-only, never establish sessions or trust caller-supplied identity, and remain only until next minor (expected 2026-09-30).
5. Prepare UI logout — POST /ui-api/auth/logout-prepare
Authenticated preparation endpoint. Requires a verified bearer session (getUiSession 401 otherwise), validates returnTo via logout policy (resolveReturnTarget logout → /ui/*), creates an opaque logout handle (chwf:ui-oidc:logout:<handle> 24 random bytes, 60 s TTL, email normalized normalizeUiIdentityEmail) bound to the canonical email + validated returnTo, and returns { logoutUrl: "/rest/auth/oidc/logout?logout=<handle>" } (authLogoutPrepareSchema returnTo ≤2048).
6. Frontend helper — GET /assets/oidc-frontend-hook.js
Redirect-only. Initial loads and SPA navigation (pushState/replaceState/popstate) to n8n /login or /signin are replaced with /ui. Logout clicks are intercepted and routed through /rest/auth/oidc/logout?returnTo=/ui. Served as a static asset with Cache-Control: public, max-age=3600; no mode switch, no form injection, no mutation observer.
Environment variables
Required
OIDC_CLIENT_IDOIDC_CLIENT_SECRETOIDC_ISSUER— required in every mode (validateN8nOidcConfig+fetchOidcDiscoveryDocument— missing issuer throwsOIDC issuer is required/OIDC issuer is required in manual endpoint mode;buildOidcRouterfails fast before serving login;verifyOidcIdTokenalways setsissuerinjwtVerifyso a correctly signed token with an unexpectedissis rejected). ConfiguredissuerUrlmust exactly equal the discoveredissuer(OIDC discovery issuer mismatch).N8N_BASE_URL— canonical deployment URL (e.g.https://n8n.example.com). Determines cookieSecureand the soleOIDC_REDIRECT_URI(${N8N_BASE_URL}/rest/auth/oidc/callback).N8N_PROTOCOL— must behttpswhenN8N_BASE_URLishttpsin production. See § Cookie.
Discovery vs manual endpoints
OIDC_ISSUERis always required. Provider metadata is resolved from/.well-known/openid-configuration(fetchOidcDiscoveryDocument);jwks_uri,authorization_endpoint,token_endpoint,userinfo_endpoint, andend_session_endpointare taken from discovery when present.- If any endpoint is not present in discovery, a manual fallback must be provided:
OIDC_AUTHORIZATION_ENDPOINT,OIDC_TOKEN_ENDPOINT,OIDC_USERINFO_ENDPOINT,OIDC_JWKS_URI. Manual mode withoutOIDC_ISSUERis not supported — issuer-less manual was removed).
Required secrets and TTLs
UI_AUTH_USE_SEPARATE_TOKEN=false→ bearer is the upstreamaccess_token;upstreamAccessTokenis stored server-side and used for CSTAR (session.upstreamAccessToken/getUiOidcAccessTokenByEmail), never the app JWT .UI_AUTH_USE_SEPARATE_TOKEN=true→ bearer is an app JWTHS256(createUiAuthToken/issueUiSessionToken). RequiresUI_AUTH_JWT_SECRETorN8N_USER_MANAGEMENT_JWT_SECRET; payloadsidis required and checked againstsessionIssueId(single slot per email, 30d). TTLmin(8h, upstream expires_in)(UI_AUTH_JWT_TTL_MS8h); separate tokens fully expired (isSeparateTokenExpired) are rejected without refresh, within 5 min window (shouldRefreshSeparateToken/UI_AUTH_REFRESH_WINDOW_MS5m) they attemptrefreshOidcTokens.- TTLs:
n8n-authcookie 24h sliding (getAuthCookieOptions), JWT inside 7d;n8n-oidc-state/nonce15m;session/logouthandles 60s each;refresh_tokencap 30d (REFRESH_TOKEN_MAX_TTL_MS);id_tokenexp-nowor 24h;acctokenreversemax(exp-now+5m,5m);tenantRoles/Groups1h; discovery 1h.
Optional
OIDC_SCOPES— defaultopenid email profileOIDC_ROLES_CLAIM— defaultrolesOIDC_END_SESSION_ENDPOINT— IDP logout URL when not in discovery; if absent, logout completes locally.SSO_RESTRICT_NO_ROLE— see § Role mapping.UI_AUTH_USE_SEPARATE_TOKEN—trueissues an app-signed JWT for UI bearer auth;falsereturns the upstreamaccess_tokendirectly.UI_AUTH_JWT_SECRET/N8N_USER_MANAGEMENT_JWT_SECRET— secret for app-issued UI JWTs.OIDC_PROVIDER_TIMEOUT_MS— default10000ms; all provider fetches (fetchOidcDiscoveryDocument,exchangeAuthorizationCode,refreshOidcTokens,fetchOidcUserInfo) usefetchWithTimeoutwithAbortController(OIDC provider request timed out), tested with bounded timeout.UI_APP_BASE_URL— default${N8N_BASE_URL}/ui; first absolute base determinestrustedBaseforreturnTopolicy.CSTAR_BASE_URL,FEATURES_ENABLED,UI_OIDC_REDIS_URL/PASSWORD/PREFIX— see canonical summarydocs/platform/user-authn-authz-summary.md:3.
Single authorization flow and both session artifacts
There is one browser authorization flow: GET /rest/auth/oidc/login → provider → GET /rest/auth/oidc/callback.
Callback outcomes:
| Outcome | n8n artifact | UI artifact | Redirect |
|---|---|---|---|
Eligible — nextRole is global:owner/admin/member | n8n-auth cookie (HttpOnly, Secure when https, SameSite=Lax, Path=/, 24 h sliding / JWT 7d) set after prepareUiSessionExchange (see Atomic issuance) | One-time exchange handle (chwf:ui-oidc:session:<handle> 60s, getDel single-use) appended as ?session=<handle> to validated returnTo (fallback /?continue=/), traded at POST /ui-api/auth/exchange | Validated returnTo with exactly one session param, preserving query/fragment |
Access-request — nextRole empty (ineligible) | None (no user created if new; existing ineligible user disabled, preserving stored role) plus Set-Cookie: clear n8n-auth on the callback response (authService.clearCookie, fallback res.clearCookie) so any prior n8n-auth (same or cross-identity) is terminated at this shared boundary | One-time exchange handle → POST /ui-api/auth/exchange yields UI bearer (canRequestAccess only) | /ui/access-request?session=<handle> with Set-Cookie: clear n8n-auth |
Failure — missing id_token/jwks_uri, signature/issuer/audience/nonce/expiry/userinfo sub mismatch, CSTAR verification error, Redis failure, provisioning failure | None | None (any handle already created is deleted via consumeUiSessionExchange/deleteUiSessionExchange idempotent; sid restored or deleted per prior existence | /ui?error=<stable public message> (no handle, no cookie; allowlist generic Authentication failed, issuer→Invalid issuer; see § Security notes) |
Valid OIDC identities always receive an external UI session; only eligible identities receive n8n-auth and n8n-derived UI capabilities (global:* and project:* via computePermissions). Ineligible/disabled identities receive canRequestAccess only.
UI exchange handles are separate from the n8n-auth cookie. The SPA trades the handle once for a bearer token; bearer tokens are either the upstream access_token or an app-issued JWT (sid bound to sessionIssueId, 8 h max, refresh window 5 min before expiry; fully expired isSeparateTokenExpired tokens are rejected without refresh; raw mode uses shouldRefreshAccessToken at/after expiresAt).
Atomic issuance guarantee (, oidc-login-coordinator.test.ts): No failed login leaves a consumable session handle or a new usable bearer; partial reftoken/idtoken/acctoken writes are best-effort cleaned via deleteUiOidcTokenRecords (idempotent, original error preserved) and cannot create a session without a valid sid (separate) or tokenemail+handle (raw), nor defeat logout (deleteUiOidcTokens deletes all).
Eligibility, provisioning, and role sync
Email is the identity key.
- Existing user found → reuse.
- New user +
nextRoleempty → no n8n user created; UI-only access-request. - New user +
nextRolepresent →createUserWithProject; first user in system becomesglobal:ownerregardless ofnextRole, later users receivenextRole. - Existing user →
syncN8nUserRolewith last-owner protection; existing ineligible user is disabled (user.disabled = true, role preserved, not synced to empty string); eligible previously-disabled user is re-enabled; then tenant mapping ensured and post-login work scheduled.
nextRole resolution is shared for new and existing users (see § Role mapping).
Post-login work (eligible only, non-blocking): prewarmTenantRolesAndGroups + syncTenantsForUser concurrently via Promise.allSettled; missing access_token or unconfigured CSTAR skips. Logged but never fails login.
Role mapping and sync
The hook reads roles from claims[OIDC_ROLES_CLAIM]:
- Parsing: comma-separated string, trimmed.
- Accepted values:
global:owner,global:admin,global:member - First valid role wins — the first element (left to right) that equals one of the three accepted slugs is used; if none matches, the mapping is empty.
Examples:
roles: "global:admin"→global:adminroles: "other-role,global:member"→global:member(first valid)roles: "global:member,other-role"→global:memberroles: "other-role"or missing → empty
Role sync behavior:
- If
SSO_RESTRICT_NO_ROLE=false,nextRoleis the mapped OIDC role orglobal:memberwhen no valid role. - If
SSO_RESTRICT_NO_ROLE=true,nextRoleis the mapped OIDC role, orglobal:memberwhen CSTAR shows a managed project role (project:editor/viewer/admin) in any tenant, or empty when neither.
A role change is applied only when currentRole !== nextRole.
SSO_RESTRICT_NO_ROLE behavior
When true:
- New users are not created if neither a valid OIDC role nor a qualifying CSTAR managed project role is present (access-request).
- Existing ineligible users are disabled (preserving stored
role.slug, not cleared) and receive UI-only session. - Previously disabled users who become eligible are re-enabled.
- If CSTAR verification fails during the fallback check, sign-in fails with
Unable to verify CSTAR tenant roles during sign-ininstead of silently treating the user as role-less.
Last owner protection
Before changing global:owner → any other role, the hook counts other global:owner users. If none, the change is blocked and the user continues signing in without changing the role. Existing disabled owners retain their DB role but computePermissions and checkRole deny all n8n-derived capabilities.
Exchange handles
- Opaque handles:
crypto.randomBytes(24).base64url, Redis keysession:<handle>, TTL 60 s,getDelsingle-use. - Created for both eligible and access-request logins; deleted if
createAuthTokenfails. - Appended via
appendSessionToReturnTo(URL.searchParams.set('session', handle)) guaranteeing exactly onesessionparam and preserving query/fragment; never appended to a rejectedreturnTofallback.
Logout ownership and revocation
Logout identity is trusted only from:
- A consumed
?logout=<handle>record (created by authenticatedPOST /ui-api/auth/logout-prepare), or - A valid
n8n-authcookie resolved viaauthService.resolveJwt.
Caller-supplied ?email= is ignored; invalid n8n-auth does not fallback to caller identity.
On logout:
authService.invalidateToken+clearCookiealways runs (clearsn8n-authon every path).deleteUiOidcTokens(email)deletesreftoken,idtoken,acctoken(+ reverse lookup),sessionIssueId,tenantroles,tenantgroups.- Server-side UI revocation: raw-token mode requires a server-known
acctokenrecord (getUiOidcAccessTokenRecordmissing → revoked); separate-JWT mode verifiessidagainst storedsessionIssueId(null/mismatch → revoked). Missingid_tokenorend_session_endpointskips upstream. - Otherwise redirects to upstream
end_session_endpointwithid_token_hint+post_logout_redirect_uri=<validated returnTo with signedOut=1>. - Provider discovery failure still completes local cleanup then redirects locally.
- Logout handles are short-lived (60 s), single-use via
getDel, and bound to the validatedreturnTofrom preparation.
Redirect policy
Every browser return target (returnTo on login/callback/logout, continue in SPA) is resolved through resolveReturnTarget(purpose, policy):
- Policy injected from
N8N_BASE_URL/UI_APP_BASE_URLat router creation:trustedBaseis the first absolute base,allowedOriginsare the origins of absolute bases + trusted base, path prefixes are/ui/*for login/logout and/for continuation. - Rejects:
//authority-relative, backslashes / encoded%5c, control characters, credentials, non-http(s)schemes, foreign origins, disallowed same-origin paths, encoded dot-segment escapes; canonicalizes../.and decodes pathname before prefix check. - Rejected candidates fall back to
buildUiAppUrl('/')(or/ui/for logout) and never receive a session handle orsignedOutmarker off-policy. - Query strings and fragments are preserved; server-added
session/signedOut/continueuseURL.searchParams.setfor exactly one occurrence. - Browser
continuedestinations are validated againstlocation.originand must remain same-origin local paths.
Callback verification
state/noncesigned cookies validated before token exchange (Missing or invalid nonce - session expiredon failure viaverifySignedCookie/validateCallbackRequest).- Missing
id_tokenorjwks_uri→Missing ID token/OIDC JWKS URI is not configured. OIDC_ISSUERis mandatory;fetchOidcDiscoveryDocumentthrowsOIDC issuer is required in manual endpoint modeif empty;buildOidcRouterthrows before serving login if not configured.jose.jwtVerifywithissuer(always set),audience,exp,noncevia a reusedcreateRemoteJWKSet(jwks_uri)per URI (jwksCache, ,clearJwksCacheForTests); discoveredissuermust equal configuredissuerUrlexactly (OIDC discovery issuer mismatch).userinfo.submust exist and equal verifiedid_tokensub; otherwiseuserinfo sub mismatch.- Signed-cookie HMAC uses
crypto.timingSafeEqualafter equal-length check. - Provider fetches are bounded via
fetchWithTimeout(AbortController+OIDC_PROVIDER_TIMEOUT_MSdefault 10s,OIDC provider request timed out).
Cookie
n8n-oidc-state, n8n-oidc-nonce (15 min), and n8n-auth (24 h) are set with HttpOnly: true, SameSite: Lax, Path: /, and Secure derived via getSecureCookieFlag:
- Parses
N8N_BASE_URLprotocol; ifhttpsthenSecure=trueelsefalse. - In production (
NODE_ENV=production), anhttpsbase withN8N_PROTOCOLnothttps, or anhttpbase withN8N_PROTOCOL=https, or a missing/invalid base withoutN8N_PROTOCOL=https, throws at startup (no silent non-Secure production cookie). - In non-production, base protocol wins when parseable, otherwise
N8N_PROTOCOL === 'https'. - Helm (
helm/main/values.yaml) andDockerfilesetN8N_PROTOCOL=httpsmatchingN8N_BASE_URLhttps;docker-composeuseshttpfor both. n8n-authis linked to the OIDC refresh lifecycle:GET /ui-api/sessionandrequireUiRequestContext(routes/ui-api.ts:38) viacreateUiRequestContextMiddleware(100) — whengetUiSessionsucceeds withrefreshedTokenandn8n-authis present, the cookie is re-issued (res.cookie('n8n-auth', sameToken, getAuthCookieOptions(isSecure))sliding 24 hhelpers/cookie.ts:66); whengetUiSessionreturnsnullwith a bearer present (OIDC expired,isRefreshTokenExpiredhelpers/ui-auth-token.ts:30or revokedsid/acctokenmissing),n8n-authis cleared (res.clearCookie('n8n-auth', {httpOnly,secure,sameSite:lax,path:'/'})48) so both sessions end together. Anonymous without a bearer never clears n8n-only session.
State cookie also carries returnTo, codeVerifier, and redirectUri; nonce cookie carries nonce.
Frontend integration
Frontend settings via frontend.settings external hook:
frontendSettings.sso.oidc.loginEnabled = truefrontendSettings.sso.oidc.loginUrl = '/rest/auth/oidc/login'frontendSettings.sso.oidc.callbackUrl = OIDC_REDIRECT_URI(${N8N_BASE_URL}/rest/auth/oidc/callback)frontendSettings.userManagement.authenticationMethod = 'oidc'frontendSettings.enterprise.oidc = true
The frontend hook keeps /ui as the browser login landing page by replacing initial loads and client-side navigation to /login or /signin with /ui. OIDC start and callback errors are delivered to /ui?error=... with stable public messages (details in server logs).
Security notes
state/noncein signed cookies mitigate CSRF/replay; signatures compared withtimingSafeEqual.- ID token is cryptographically verified (signature, issuer — always required — audience, expiry, nonce) via a reused JWKS resolver per
jwks_uri;userinfois bound to the same subject. - Cookie signing key derived from
N8N_ENCRYPTION_KEY(orOIDC_CLIENT_SECRETfallback) via SHA-256 with-oidc-statesuffix. - Valid email required before provisioning.
- Public error boundaries (,
oidc.test.ts/oidc-provider.test.ts):ALLOWED_OIDC_PROVIDER_ERROR_CODESallowlist (19 codes) andSTABLE_PUBLIC_ROUTE_MESSAGESallowlist map provider/route errors to stable public codes/messages; unknown or hostile texts (e.g.<script>,redis://, raw infrastructure) are mapped to genericAuthentication failed(or issuer mismatch →Invalid issuer) and detailed causes are logged server-side vialogError; no raw provider text is placed in?error=redirects. - Token refresh TTLs: refresh token capped to
min(provider remaining, 30d)(setUiOidcRefreshTokenWithExpiry);UIbearer 8 h max with 5-min refresh window (UI_AUTH_REFRESH_WINDOW_MS); fully expired separate tokens (isSeparateTokenExpired) rejected without refresh, rawshouldRefreshAccessTokenat/afterexpiresAt;n8n-auth24 h cookie slides on UI refresh (extendN8nAuthCookiewhenX-UI-Auth-Tokenpresent) and is cleared when OIDC session expires/revoked and a bearer was presented (clearN8nAuthCookie/shouldClearN8nCookieOnExpiry; anonymous without bearer never clears n8n-only session) linked viacreateUiRequestContextMiddleware. n8n-authand UI bearer are not interchangeable: UI bearer never authenticatesGET /rest/auth/oidc/*(those use cookie or handle),n8n-authnever authenticates/ui-api/*(those useAuthorization: BearerviarequireUiRequestContextMiddleware).- Upstream CSTAR calls use the server-side upstream token (
session.upstreamAccessTokenresolved viagetUiOidcAccessTokenByEmailorrefreshAccessTokenclosure), never the presented UI bearer in separate-token mode (,ui-api.test.tsseparate-token regression). - Race safety (,
ui-oidc-store-refresh.test.ts):setUiOidcAccessTokenRecorduses Lua CAS (SET_ACCESS_TOKEN_LUA) and per-email in-process lock;deleteUiOidcTokensuses verify-after-DEL + orphan sweep;refreshSessionByEmailuses single-flight per normalized email (REFRESH_SINGLE_FLIGHT_TIMEOUT_MS10s).
Operational notes
- Required env missing or
OIDC_ISSUERabsent → routes not registered, warning logged;buildOidcRouterthrowsOIDC issuer is requiredso an invalid security configuration fails before serving login . - Discovery cached in memory for 1 h (
fetchOidcDiscoveryDocument); JWKS resolvers reused perjwks_uriwhile preservingjosekey rotation onkidmiss. - Provider fetches bounded by
OIDC_PROVIDER_TIMEOUT_MS(default 10s) viafetchWithTimeout. GET /ui-api/auth/loginandGET /ui-api/auth/logoutaliases are redirect-only (302) until2026-09-30; they do not establish sessions or trust caller identity (canonical isGET /rest/auth/oidc/*).- OIDC failures surfaced via
/ui?error=...with allowlisted stable public messages (browser) andlogError(server);?email=on logout is untrusted. - Logging via
external-hooks/src/api/utils/logger.ts;Open n8naffordance is hidden for anonymous/loading/disabled/role-less users (defense-in-depth, server-siden8n-authremains authoritative —.