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 shown8n-authpresent withHttpOnly,Secure,SameSite=Laxon normal page loads/API calls). SameSite=Laxshould 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_HOPSmisconfiguration (both set correctly for the Traefik hop)N8N_SECURE_COOKIE=false(tested, no change)- Multiple replicas causing different
N8N_ENCRYPTION_KEYper 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
- Self-host n8n behind Traefik/Cloudflare as described above.
- Create a YouTube OAuth2 API credential (or any OAuth2 credential) with a valid Client ID/Secret from Google Cloud Console.
- Click “Sign in with Google” / “Connect my account”.
- Complete the Google consent screen.
- 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.userIdvsreq.user?.id, or explicitly log whenreq.useris 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.