Auth.js v5 redirects to the wrong URL behind a reverse proxy
It works on your laptop. You deploy behind Nginx, or put Cloudflare in front, and sign-in suddenly sends people to http://localhost:3000, or to a Docker container name like http://web:3000, or to the pod's internal IP. The OAuth provider complains that the redirect URI does not match. You do what every v4 tutorial says and set NEXTAUTH_URL, restart, and nothing changes.
Nothing changes because you are fixing a version of the library you are not running. Auth.js v5 threw out the model where you hard-code the canonical URL in an environment variable. It now derives the URL from the request at runtime, and behind a proxy that derivation is gated by a single setting most migration guides mention in one line and then never explain. This is that explanation.
v5 does not read NEXTAUTH_URL. It builds its base URL from the incoming request host, and behind a proxy it will only trust the X-Forwarded-Host header when trustHost is on.
- Set
AUTH_TRUST_HOST=true(ortrustHost: truein the config) for any self-hosted deployment behind Nginx, Cloudflare, Traefik or a load balancer. - Make the proxy forward
X-Forwarded-Hostwith your public hostname andX-Forwarded-Proto https. Without the proto header you gethttp://callback URLs on an HTTPS site. - Only set
AUTH_URLif you serve Auth.js under a sub-path. For a normal root deployment you do not need it. NEXTAUTH_URLis a v4 variable. In v5 it does nothing. Delete it so it stops misleading you.
The symptom: "Authjs v5 redirecting to the wrong URL"
The phrase people search is exactly that, and the concrete forms it takes are:
- After sign-in, browser lands on http://localhost:3000/... in production
- Redirect goes to http://web:3000 or http://10.0.x.x (the container/pod address)
- OAuth provider error: redirect_uri_mismatch
- Callback URL built as http:// on an https:// site, cookies rejected as insecure
All of these are the same underlying event: Auth.js constructed an absolute URL from the wrong host, or the wrong protocol, and then either redirected the browser there or sent it to the identity provider as the callback.
Why this happens: v5 infers the host, and inference is off behind a proxy by default
In v4, you told the library the truth once: NEXTAUTH_URL=https://example.com. Every absolute URL was built from that constant. Simple, but it meant a separate variable per environment and a whole class of "I forgot to set it in staging" bugs.
v5 flips the model. It reads the host from the request that actually arrived, so one build serves any domain. The catch is that the request your Next.js server sees behind a proxy is not the request the browser made. The browser asked for https://example.com. Nginx received that, then made a fresh request to your app at http://web:3000. As far as your app's raw Host header is concerned, its name really is web:3000.
The proxy preserves the original host in X-Forwarded-Host and the original scheme in X-Forwarded-Proto. But those headers are trivially spoofable if you accept them from anyone, so Auth.js refuses to trust them unless you explicitly say the app is behind a trusted proxy. That switch is trustHost.
On a recognised managed host such as Vercel, Auth.js detects the environment and turns trustHost on for you. That is why it works there and breaks the moment you self-host. The default is safe, not convenient, and behind your own proxy you have to opt in.
The fix, in steps
Step 1: Turn on host trust
Set the environment variable in your deployment:
AUTH_TRUST_HOST=true
Or set it directly in the Auth.js config, which is clearer when you want it tied to the code rather than the environment:
// auth.ts
import NextAuth from "next-auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [ /* ... */ ],
});
This is the single change that resolves the large majority of these reports. It tells Auth.js it may build its URL from X-Forwarded-Host.
Step 2: Make the proxy actually send the forwarded headers
Trusting the headers is pointless if the proxy is not sending them, or is sending the wrong values. For Nginx:
location / {
proxy_pass http://web:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
The two that matter for this bug are X-Forwarded-Host, which must carry your public hostname, and X-Forwarded-Proto, which must be https on a TLS site. If you terminate TLS at Cloudflare and run plain HTTP to your origin, $scheme at your origin Nginx may be http; in that case hard-code proxy_set_header X-Forwarded-Proto https; so the app knows the public scheme, not the internal one. A correct baseline proxy config is worth keeping around; I walk through one in the Nginx reverse proxy config you should actually be running.
Step 3: Only add AUTH_URL if you are on a sub-path
For a root deployment, stop here. Do not add AUTH_URL, because inference now works and a hard-coded URL just reintroduces the per-environment variable you were trying to escape. Add it only when Auth.js lives under a base path:
# Only needed for a non-root mount point
AUTH_URL=https://example.com/app/api/auth
When set, AUTH_URL overrides inference entirely, which is also the escape hatch for the rare case where you genuinely cannot make the header chain trustworthy end to end.
Step 4: Delete NEXTAUTH_URL
Remove NEXTAUTH_URL from every environment. It does nothing in v5, and leaving it there guarantees the next person who debugs this will waste an hour tuning a dead variable, exactly as you did.
Verification: read the callback the redirect callback actually builds
Do not guess which host won. Look at it. Add a temporary log in the redirect callback:
callbacks: {
async redirect({ url, baseUrl }) {
console.log("authjs redirect", { url, baseUrl });
if (url.startsWith("/")) return `${baseUrl}${url}`;
if (new URL(url).origin === baseUrl) return url;
return baseUrl;
},
},
Then:
- Trigger a sign-in and read the log.
baseUrlmust behttps://example.com. If it ishttp://web:3000orhttp://localhost:3000, trustHost or the forwarded headers are still wrong, and no amount of tweaking the callback body will save you, becausebaseUrlis built before your callback runs. - Check the outbound request to the OAuth provider in the browser network tab. The
redirect_uriquery parameter must be your public HTTPS callback, not an internal one. - Confirm the session cookie is set with the
Secureattribute and no host mismatch warning. Anhttp://baseUrl on an HTTPS site produces a cookie the browser will refuse. - Remove the log once
baseUrlis correct.
What people get wrong
Setting NEXTAUTH_URL and expecting it to work. This is the single most repeated non-fix, because it was the correct answer for years under v4. In v5 it is inert. The variable name even survives in old blog posts and Stack Overflow answers, so it feels authoritative. It is simply the wrong version.
Hard-coding AUTH_URL instead of fixing header trust. Setting AUTH_URL to your public domain will paper over the symptom for a single-domain app, and plenty of people stop there. But you have now disabled inference, so a multi-domain or preview-deployment setup that relies on per-request host detection breaks, and you have reintroduced the per-environment variable v5 was designed to remove. Fix trustHost and the headers; reach for AUTH_URL only for a real sub-path.
Turning on trustHost without securing the proxy. trustHost: true means "believe X-Forwarded-Host". If a client can reach your app directly, bypassing the proxy, it can spoof that header and make Auth.js mint callback URLs pointing at an attacker's host. Make sure the app only accepts traffic from the proxy, on a private network or firewalled port, before you trust forwarded headers.
Forgetting X-Forwarded-Proto. People fix the host and leave the scheme, then wonder why cookies vanish and OAuth rejects an http:// redirect URI on an HTTPS site. The protocol is half the URL. Forward it.
When it is still broken
Multi-tenant or multi-domain, where each customer has their own host. This is where inference earns its keep: leave AUTH_URL unset, keep trustHost on, and make sure every domain forwards X-Forwarded-Host correctly, so each request resolves to its own origin. Do not set a single AUTH_URL, or every tenant collapses onto one host.
A second proxy layer overwrites the header. Cloudflare in front of Nginx in front of the app means two hops, and a misconfigured middle hop can rewrite X-Forwarded-Host to its own view. Log the header value at the app to see what actually arrives, then fix whichever hop is lying.
Redirect is right but the session drops after sign-in. That is usually a cookie domain or Secure/SameSite issue rather than a host issue, and it is a different failure mode; if users get silently logged out on navigation, look at token and cookie handling, along the lines of the token rotation bug that logs users out.
The mental shift is the whole fix: v5 does not want you to tell it its URL, it wants you to tell it whom to trust. Once trustHost and the forwarded headers agree, the wrong-URL redirect stops for good, and one build serves every domain you point at it.
Frequently asked questions
- Why does Auth.js v5 ignore NEXTAUTH_URL?
- Because NEXTAUTH_URL is a v4 variable and v5 does not use it. In v5 the base URL is inferred from the incoming request headers, gated by trustHost. The equivalent knobs are AUTH_TRUST_HOST (to trust proxy headers) and, only if you use a non-root base path, AUTH_URL. Setting NEXTAUTH_URL in v5 has no effect on host detection.
- What is trustHost and why do I need it behind a proxy?
- trustHost tells Auth.js it is allowed to derive its own host from the X-Forwarded-Host header. Off a known host like Vercel it defaults on. Self-hosted behind Nginx or Cloudflare it defaults off, so Auth.js falls back to the raw request host, which is your container's internal name or IP, and every absolute redirect points there. Set AUTH_TRUST_HOST=true or trustHost:true to fix it.
- Which headers must my reverse proxy forward for Auth.js v5 to build correct URLs?
- At minimum X-Forwarded-Host with the public hostname and X-Forwarded-Proto with 'https'. Without X-Forwarded-Proto set to https, Auth.js can build an http:// callback URL even on an HTTPS site, which breaks OAuth providers that require https redirect URIs and can cause insecure-cookie problems.
- Do I still need AUTH_URL in v5?
- Usually no. The host is inferred from the request, so AUTH_URL is optional. You need it only when your app is served under a sub-path (for example https://example.com/app/api/auth) or when header inference genuinely cannot be trusted end to end. In that case set the full URL including the base path, and it overrides inference.