OAuth2 credential callback returns "Unauthorized" — n8n-auth cookie not sent on Google redirect

Environment

  • n8n version: 2.29.8 (self-hosted)
  • Database: PostgreSQL
  • Deployment: Docker Swarm, via EasyPanel
  • Reverse proxy: Traefik 3.6.7 (in front of n8n), with Cloudflare (proxied) in front of Traefik
  • n8n runs on a single replica (confirmed via docker service ps, no multi-replica scenario)
  • Environment variables set: N8N_HOST, N8N_PROTOCOL=https, WEBHOOK_URL, N8N_PROXY_HOPS=1, N8N_ENCRYPTION_KEY (fixed, not auto-generated), GENERIC_TIMEZONE

Summary

Any OAuth2 credential (tested with YouTube OAuth2 API, Google Cloud Console) fails at the final callback step with a generic Error: Unauthorized — Failed to connect. The window can be closed now. The authorization URL is generated correctly, the user completes Google’s consent screen without issue, and Google redirects back to https://<host>/rest/oauth2-credential/callback with a valid code and state — but the popup window returns the generic Unauthorized error instead of completing the credential save.

Root cause identified (source-level)

In packages/cli/src/oauth/oauth.service.ts, the callback handler decodes the CSRF state and checks:

js

if (!skipAuthOnOAuthCallback && decryptedState.userId !== req.user?.id) {
  throw new AuthError('Unauthorized');
}

Using browser DevTools on the OAuth popup window during the redirect back from accounts.google.com, I confirmed that the n8n-auth cookie is not present in the Cookies tab of the /rest/oauth2-credential/callback request, even though:

  • The main n8n editor tab is fully authenticated at the time (session confirmed valid, document.cookie / Application > Cookies show n8n-auth present with HttpOnly, Secure, SameSite=Lax on normal page loads/API calls).
  • SameSite=Lax should permit the cookie on a top-level GET navigation from a cross-site redirect (which is what Google’s OAuth callback redirect is).

Because req.user ends up undefined on that specific request, it never matches decryptedState.userId, and the generic AuthError('Unauthorized') is thrown — with no log output, even at N8N_LOG_LEVEL=debug. Traefik access logs confirm the request reaches n8n and gets a 200 response (938 bytes — the static error page), so this isn’t a proxy/routing issue upstream.

What I’ve already ruled out

  • Redirect URI mismatch (confirmed identical in Google Cloud Console and n8n credential screen)
  • Client ID / Client Secret mismatch (regenerated both, recreated credential from scratch)
  • Wrong/expired OAuth consent screen test user (app is in Production status, not Testing)
  • YouTube Data API v3 not enabled (confirmed enabled)
  • Server clock drift (confirmed correct, only timezone display differs)
  • N8N_PROTOCOL / N8N_PROXY_HOPS misconfiguration (both set correctly for the Traefik hop)
  • N8N_SECURE_COOKIE=false (tested, no change)
  • Multiple replicas causing different N8N_ENCRYPTION_KEY per instance (confirmed single replica; also fixed the encryption key explicitly regardless)
  • Cloudflare proxy interference (tested with the subdomain fully unproxied — DNS only — same result)
  • Browser-specific issue (tested Chrome, Firefox, incognito, different Google account, different machine)
  • Database connectivity issues at the time of the callback (confirmed Postgres healthy, no errors logged in that time window)

Steps to reproduce

  1. Self-host n8n behind Traefik/Cloudflare as described above.
  2. Create a YouTube OAuth2 API credential (or any OAuth2 credential) with a valid Client ID/Secret from Google Cloud Console.
  3. Click “Sign in with Google” / “Connect my account”.
  4. Complete the Google consent screen.
  5. Observe: popup returns “Error: Unauthorized — Failed to connect” instead of closing successfully.

Expected behavior

The n8n-auth cookie should be sent by the browser on the callback redirect (per SameSite=Lax semantics), req.user should resolve correctly, and the credential should save the access/refresh token as normal.

Additional notes

  • Would appreciate guidance on whether this error path could log the actual mismatch (e.g., decryptedState.userId vs req.user?.id, or explicitly log when req.user is undefined) instead of a silent generic Unauthorized — this would make self-diagnosis significantly easier for self-hosted users behind reverse proxies.
  • Happy to provide additional logs, HAR files from the popup’s Network tab, or test further configuration changes if useful for triage.