Skip to content
tecminds

Auth.js UntrustedHost Behind Coolify/Traefik — AUTH_TRUST_HOST, AUTH_URL Without a Trailing Slash, and the callback_url That Stayed on localhost:3000

A login that works on localhost and on Vercel dies on a Coolify-plus-Traefik Next.js App Router stack. Support hears the login loops, Google says redirect_uri_mismatch, the server prints UntrustedHost: Host must be trusted, and the redirect after sign-in lands on localhost:3000. Auth.js v5 builds every auth URL from X-Forwarded-Host/Proto or from AUTH_URL — Vercel sets trustHost for you, Docker does not. Fix the proxy headers first, then AUTH_TRUST_HOST=true, then an AUTH_URL that is the public origin with no trailing slash. This is the field note.

TTobias LüscherCo‑Founder · TecMinds2026-09-24 · 18 min read

Auth.js UntrustedHost Behind Coolify/Traefik — AUTH_TRUST_HOST, AUTH_URL Without a Trailing Slash, and the callback_url That Stayed on localhost:3000

The most expensive login is the one that already looked signed in. We shipped an Auth.js v5 upgrade on a quiet afternoon — the Coolify-plus-Traefik self-hosted Next.js App Router stack we already run for Swiss SME products like Acurio — and the same signIn("credentials") and signIn("google") that returned a session on localhost:3000 and on a Vercel preview died in production. Support opened it as "login loops back to /login." A second ticket said "Google shows redirect_uri_mismatch." A third pasted a screenshot of the address bar reading http://localhost:3000/dashboard — on a customer's laptop, in a Zurich office, with no dev server anywhere near it. The container was green. The database had the user. The server log printed:

[auth][error] UntrustedHost: Host must be trusted. URL was: "http://nextjs:3000/api/auth/session". Read more at https://errors.authjs.dev#untrustedhost

This is the writeup of Auth.js behind Coolify/Traefik — why UntrustedHost fires in Docker and never on Vercel, what Auth.js actually reads to build its own URL (X-Forwarded-Host / X-Forwarded-Proto, or AUTH_URL), why AUTH_TRUST_HOST is a presence flag and not a URL, why a copied AUTH_URL=http://localhost:3000 pins every callback to your laptop, and why you fix the proxy headers before you touch a single env var.

The stack is the one we wrote up last week from the Server Actions side. Long-lived Coolify holds the Next.js container, Postgres, and Traefik. Vercel is the control that sets trustHost for you and forwards the public host. A local next dev sets trustHost for you too, for a different reason. Coolify-plus-Traefik does neither. The Server Actions CSRF note already named the hop that lies: Traefik stamping X-Forwarded-Host with the compose service name. That note was Next's CSRF check comparing Origin to the forwarded host. This note is Auth.js building URLs from the same forwarded host. Sibling failures. Same proxy. Different layer.

Four Tickets, One Origin

Auth.js does not store your public URL anywhere. On every request to /api/auth/* it computes it, and then uses that computed origin for everything that matters: the redirect_uri it sends to Google, the callbackUrl it stores in the authjs.callback-url cookie, the base it resolves your relative redirectTo: "/dashboard" against, the pages.signIn redirect when a session is missing, and the decision whether cookies get the __Secure- / __Host- prefix. Get the origin wrong and you do not get one error. You get four tickets that look unrelated.

UntrustedHost: Host must be trusted is the honest one. Auth.js refuses to compute an origin from request headers unless you told it the headers are trustworthy. In production, in a container, with no AUTH_TRUST_HOST, /api/auth/session returns a 500, auth() in your layout returns null, and the UI shows the logged-out state for a user who has a perfectly valid session cookie. That is the "login loops" ticket. The user signs in, the cookie is set, the next render cannot read it, middleware sends them to /login.

redirect_uri_mismatch is Google reading the redirect_uri Auth.js built and comparing it to the console. Auth.js built http://nextjs:3000/api/auth/callback/google or http://localhost:3000/api/auth/callback/google. You registered https://app.example.ch/api/auth/callback/google. Google is right. The origin Auth.js used is the bug.

The address bar on localhost:3000 is the redirect callback doing its job. The default redirect callback allows relative URLs and URLs on the same origin as baseUrl; anything else falls back to baseUrl. If baseUrl is http://localhost:3000 because that is what AUTH_URL says, then a callbackUrl of https://app.example.ch/dashboard is a foreign origin, and the user is sent to http://localhost:3000 — connection refused on a customer laptop, or straight onto a developer's own dev server, logged out, looking exactly like a login loop.

InvalidCheck — "PKCE, state or nonce OAuth check could not be performed" — is the cookie that was set on one origin and read on another. Sign-in started on https://www.app.example.ch, AUTH_URL said https://app.example.ch, so redirect_uri sent the browser back to the apex, where the authjs.pkce.code_verifier cookie the www host set does not exist. Same failure if the callback lands on a localhost redirect_uri that a developer registered in the Google console for local work. The passwordChangedAt note already taught us that session failures love a costume. All four of these wear one.

What Auth.js Actually Reads

Two functions in @auth/core decide the whole afternoon. Read them once and the four tickets collapse into one.

The first sets trustHost when you did not:

// @auth/core, lib/utils/env.ts (setEnvDefaults) — abbreviated
config.trustHost ??= !!(
  envObject.AUTH_URL ??
  envObject.AUTH_TRUST_HOST ??
  envObject.VERCEL ??
  envObject.CF_PAGES ??
  envObject.NODE_ENV !== "production"
);

That ?? chain is the entire "works everywhere except Coolify" story. On Vercel, VERCEL is set: trusted. On Cloudflare Pages, CF_PAGES: trusted. In next dev, NODE_ENV is not production: trusted. In a Docker container built with next build and started with node server.js, none of those are true, and unless you set AUTH_TRUST_HOST or AUTH_URL, trustHost is false and every /api/auth/* request throws UntrustedHost. Localhost did not prove anything. Vercel did not prove anything. Both were trusted for reasons that do not exist in your container.

The second builds the URL Auth.js believes it lives at:

// @auth/core, lib/utils/env.ts (createActionURL) — abbreviated
const envUrl = envObject.AUTH_URL ?? envObject.NEXTAUTH_URL;
if (envUrl) {
  url = new URL(envUrl); // origin wins; pathname is treated as basePath
} else {
  const host = headers.get("x-forwarded-host") ?? headers.get("host");
  const proto = headers.get("x-forwarded-proto") ?? protocol ?? "https";
  url = new URL(`${proto}://${host}`);
}

Two branches, one rule: if AUTH_URL is set, it wins; if it is not, X-Forwarded-Host then Host, and X-Forwarded-Proto for the scheme. next-auth adds one more step on top — with AUTH_URL set it rewrites the incoming request's origin to the env origin before Auth.js ever sees it (reqWithEnvURL). So AUTH_URL=http://localhost:3000 does not "help Auth.js find itself." It tells Auth.js it is localhost:3000, for every request, from every customer, and it will build every redirect on that belief.

Which means there are exactly two ways to be wrong, and they look identical from the browser. Either AUTH_URL is set to the wrong origin — the .env.local value that followed a copy-paste into the Coolify environment tab — or AUTH_URL is not set and the proxy is forwarding the wrong host or scheme. The UntrustedHost message already printed the answer for the second case. Read the URL in it. http://nextjs:3000/api/auth/session is the request as the container saw it: the compose service name in the host, http where the browser typed https. Traefik told on itself in the error you were trying to make go away.

AUTH_TRUST_HOST=true Is a Presence Flag, Not a URL

The deployment docs say it in one sentence: behind a reverse proxy, set AUTH_TRUST_HOST to true, or trustHost: true in the config. That tells Auth.js the X-Forwarded-Host header is written by a hop you control.

# Coolify → your app → Environment Variables (runtime, not build)
AUTH_SECRET=<32+ random bytes, from `npx auth secret`>
AUTH_TRUST_HOST=true

Or, if you would rather not depend on an env var being present on the next box that hosts this container:

import NextAuth from "next-auth";
import Google from "next-auth/providers/google";

export const { handlers, auth, signIn, signOut } = NextAuth({
  trustHost: true, // behind Traefik / Coolify; Vercel and next dev infer this
  providers: [Google],
  // …
});

Look at the ?? chain again before you type anything else into that field. The check is presence, not value. AUTH_TRUST_HOST=false is a non-empty string, !!"false" is true, and Auth.js will trust the host. The way to turn it off is to remove the variable. The way to turn it on is the literal true. We saw a PR set AUTH_TRUST_HOST=https://app.example.ch — it "worked," because any non-empty value works, and the reviewer then spent an hour wondering why the URL in that variable had no effect on the redirect. It has none. It is not a URL slot. The URL slot is AUTH_URL, and you may not need it.

Two more things the chain tells you. Setting AUTH_URL also flips trustHost on, which is why teams that copied AUTH_URL=http://localhost:3000 into Coolify never saw UntrustedHost — they went straight to the localhost redirect and never got the honest error. And trustHost is not a security downgrade in itself: it says "believe the proxy." The security question is whether the proxy deserves it, which is the next section, not this one.

Fix the Proxy Headers First

trustHost: true means Auth.js will build redirect_uri, callbackUrl, and cookie flags from whatever Traefik put in X-Forwarded-Host and X-Forwarded-Proto. If the last hop the Next.js container sees writes nextjs:3000 and http, then you have trusted a liar, and AUTH_TRUST_HOST=true turns UntrustedHost into redirect_uri_mismatch — a worse error, because now Google is involved. Be honest with the proxy before you trust it.

Write the values down before anyone restarts the Coolify service. A throwaway Route Handler you delete after the incident:

// app/api/debug-auth-origin/route.ts — delete after the incident
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

export async function GET(request: Request) {
  const h = request.headers;
  const forwardedHost = h.get("x-forwarded-host");
  const host = h.get("host");
  const proto = h.get("x-forwarded-proto");

  return NextResponse.json({
    host,
    xForwardedHost: forwardedHost,
    xForwardedProto: proto,
    // what createActionURL will do when AUTH_URL is unset:
    computedOrigin: `${proto ?? "https"}://${forwardedHost ?? host}`,
    // presence only — never print the values
    env: {
      AUTH_URL: Boolean(process.env.AUTH_URL),
      NEXTAUTH_URL: Boolean(process.env.NEXTAUTH_URL),
      AUTH_TRUST_HOST: Boolean(process.env.AUTH_TRUST_HOST),
      VERCEL: Boolean(process.env.VERCEL),
    },
  });
}

You want computedOrigin to read https://app.example.ch. Ours read http://nextjs:3000. That line, not the Auth.js config, was the incident.

Traefik's passHostHeader defaults to true and is necessary and not sufficient — the Server Actions note already walked through why an inner hop can still stamp the compose name into X-Forwarded-Host. Same middleware fixes both layers:

# Traefik file provider sketch. Coolify labels are the same middleware.
http:
  middlewares:
    public-forwarded-host:
      headers:
        customRequestHeaders:
          X-Forwarded-Host: "app.example.ch"
          X-Forwarded-Proto: "https"

Coolify already knows the public domain you attached to the service — it is the same string it exposes to the container as COOLIFY_FQDN / COOLIFY_URL. The job is to make that string the value of X-Forwarded-Host, not the Docker DNS name Traefik used to find port 3000. If you reach for COOLIFY_URL to derive AUTH_URL later, know that a service with two attached domains gets a comma-separated list there. One origin. Pick it.

X-Forwarded-Proto is the half nobody checks. Auth.js decides useSecureCookies from the scheme of the URL it computed. https gives you __Secure-authjs.session-token and __Host-authjs.csrf-token; http gives you authjs.session-token with no Secure flag. A proxy that terminates TLS and then forgets to say so makes Auth.js issue production cookies as if you were on plain HTTP. If Traefik itself sits behind Cloudflare or a tunnel, Traefik will overwrite inbound X-Forwarded-* from a source it does not trust — that is correct behaviour and it is why the entryPoint forwardedHeaders.trustedIPs exists. Trust the upstream hop, or Traefik will report http for a request the user made over https.

Do not take X-Forwarded-Host from the client. The browser can send whatever it wants, and with trustHost: true Auth.js will build a redirect_uri on it. Traefik sets the header; the container trusts only the proxy hop. The URL-token portal note said this for X-Forwarded-For and IP-keyed limits. Same header family, same rule: the hop you trust must be the hop that wrote the header.

Redeploy the proxy config, not only the Next.js image. We burned a Coolify restart on the web service while Traefik kept the old labels. The container came up green. computedOrigin stayed http://nextjs:3000.

Then, and Only Then, AUTH_URL

With an honest proxy and AUTH_TRUST_HOST=true, the deployment docs are blunt: AUTH_URL is "mostly unnecessary with v5 as the host is inferred from the request headers." Leave it unset. Every URL Auth.js builds will follow the public host and scheme Traefik forwards, including the day marketing moves you from app.example.ch to portal.example.ch, with no env change.

Set it when one of two things is true. You run Auth.js on a different base path than /api/auth, in which case AUTH_URL carries the path and Auth.js derives basePath from it. Or you have a hop you cannot make honest — an inherited proxy, a second internal domain that also reaches the container — and you would rather pin the origin than trust headers. In both cases the value is the public origin, https, and it is exact:

# Good — origin only, no trailing slash. basePath stays /api/auth.
AUTH_URL=https://app.example.ch

# Good — only if your handler really lives at a custom base path.
# next-auth reads the pathname and makes it the basePath.
AUTH_URL=https://app.example.ch/api/auth

# Bad — the .env.local that followed a copy-paste into Coolify.
# Every redirect_uri and callbackUrl is now built on your laptop.
AUTH_URL=http://localhost:3000

# Bad — wrong suffix. basePath becomes /auth, your route lives at /api/auth,
# client signIn() POSTs to /auth/signin and 404s; the handler can no longer
# parse actions on its own path (UnknownAction).
AUTH_URL=https://app.example.ch/auth

# Bad — the internal name. UntrustedHost goes away, redirect_uri_mismatch arrives.
AUTH_URL=http://nextjs:3000

The trailing-slash story is smaller than the folklore and still worth one line. Auth.js strips a trailing slash from the origin before appending actions, so https://app.example.ch/ on its own does not break. A trailing slash after a base path is where it stops being free: next-auth takes the pathname as basePath verbatim, and now basePath is /api/auth/ where your route and your OAuth console say /api/auth. It mostly still parses; it also logs env-url-basepath-mismatch when the two disagree, and it is the reason the redirect_uri in the Google console and the one in the network tab differed by one character in a case we did not enjoy. Write the origin. If you must write the path, write exactly the path the route lives at. Nothing after it.

NEXTAUTH_URL is the v4 name and Auth.js still honours it as a fallback. If both are set, AUTH_URL wins. If you migrated from v4 and Coolify still holds a NEXTAUTH_URL=http://localhost:3000 from an old import, you have a localhost pin you cannot see in the new variable list until you search for it. Delete the old one. Do not carry two.

And do not set AUTH_URL to make UntrustedHost disappear. It will disappear — AUTH_URL flips trustHost on — and you will have replaced an honest error with a pinned origin that goes stale the first time the domain changes. AUTH_TRUST_HOST=true plus an honest proxy is the fix. AUTH_URL is a decision about base paths, not a painkiller.

The Costumes That Steal the Afternoon

Five other failures present as "login is broken" on this stack. Name them so they do not eat the trustHost ticket.

passwordChangedAt and friends. A jwt callback that drops the token on a missing custom claim bounces a fresh login to /login on request N+1 — the passwordChangedAt note in full. It looks exactly like UntrustedHost from the browser. It does not print Host must be trusted. Grep the log before you touch env.

Middleware that redirects on the internal URL. new URL("/login", request.url) resolves against the request as the container saw it. If Traefik rewrote Host to the service name, that redirect goes to http://nextjs:3000/login and the browser cannot resolve it. The Cron middleware section already had this matcher stealing a GET; here it steals the redirect target. Fix X-Forwarded-Host and passHostHeader and the redirect fixes itself.

Server Actions CSRF on the same proxy. A <form action={signInAction}> that calls signIn() inside a Server Action can abort before Auth.js runs, with x-forwarded-host does not match origin. That is the Server Actions note, not this one — but it is the same lying header. One honest X-Forwarded-Host closes both. If you only widen allowedOrigins, Auth.js will still build redirect_uri on the internal name one layer down.

Vercel green, Coolify broken. The same commit passes on a Vercel preview because VERCEL is set and the platform forwards the public host. That deploy proves the code. It proves nothing about trustHost in your container or about Traefik. There is no Deployment Protection on Coolify to blame either — the cron note's SSO wall does not exist here; if the callback 401s or 500s, it is your handler or your proxy. Reproduce on the public Coolify domain, with the debug route, before you read one more forum thread.

Env that did not reach the process. Coolify distinguishes build-time and runtime variables and does not hot-reload either. AUTH_TRUST_HOST added in the UI is inert until the service is redeployed. AUTH_SECRET missing in production prints MissingSecret, not UntrustedHost, and also kills every /api/auth/* call; read which one you have. A NEXT_PUBLIC_* variable does not need to exist for Auth.js to work — it is server-side, and the URL it builds is not something the client bundle should be hardcoding either.

None of those is a reason to set AUTH_URL to whatever makes the error go away. The error is a measurement. Change the thing it measured.

The Checklist We Run After a Login That "Loops"

Five checks, in this order, before anyone is allowed to edit the Auth.js config.

Read the server string, not the browser. UntrustedHost: Host must be trusted is this note — and the URL in that line is the container's view of the request; if it says http:// or an internal host, the proxy is already implicated. redirect_uri_mismatch is the origin Auth.js computed versus the OAuth console. InvalidCheck is a state/PKCE cookie set on a different origin than the callback arrived on. MissingSecret is AUTH_SECRET. A /login bounce with no Auth.js error at all is a jwt callback or middleware.

Localhost and Vercel are not Coolify. NODE_ENV !== "production" trusts the host in next dev. VERCEL trusts it on Vercel. Your container has neither. Reproduce on the public Coolify domain and hit /api/debug-auth-origin there.

Fix X-Forwarded-Host and X-Forwarded-Proto to the public host and https. Traefik last hop. passHostHeader, an explicit public X-Forwarded-Host if an inner hop overwrote it, forwardedHeaders.trustedIPs if Traefik is behind another proxy. Trust only the proxy. Redeploy the proxy, then the app. When computedOrigin reads https://app.example.ch, continue; not before.

AUTH_TRUST_HOST=true, literal, runtime, redeploy. Or trustHost: true in config. Presence is what counts; false is not off. Not a URL. Then confirm /api/auth/session returns JSON instead of a 500.

AUTH_URL only if you need it, and then the public origin, exact. No localhost. No internal name. No trailing slash after a base path. No /auth where the route is /api/auth. Delete any leftover NEXTAUTH_URL. If the proxy is honest and the base path is default, leave it unset and let the headers carry the origin.

Three Rules That Survive the Next Proxy Flag

Three rules survive this writeup and generalise past whatever Coolify calls a domain next year.

Auth.js does not know its URL. It computes it, per request, from one of two sources. AUTH_URL if set; otherwise X-Forwarded-Host and X-Forwarded-Proto. Every redirect_uri, every callbackUrl, every cookie prefix follows that computation. A login that lands on localhost:3000 is not a browser bug. It is the origin you gave it.

trustHost is a presence flag about the proxy, not a URL about the app. Vercel and next dev set it for you for reasons your container does not have. AUTH_TRUST_HOST=true says "believe Traefik." Make Traefik worth believing first: public host, https, trusted upstream. Then, and only then, flip the flag.

AUTH_URL is a base-path decision, not a painkiller. It also silences UntrustedHost, which is how a copied .env.local pins production to a laptop and nobody sees the honest error. Public origin, no trailing slash after a base path, or unset. The Server Actions CSRF abort on the same box is the sibling: same lying header, one layer up. Fix the header and both go quiet.

The composition is the note. We treated a looping login as broken Auth.js config because localhost was green, Vercel was green, and the Coolify container was green. Production was a Traefik hop that forwarded nextjs:3000 over http, a container with no AUTH_TRUST_HOST because nothing else had ever needed it, and an AUTH_URL that still said localhost:3000 from the day the repo was cloned. One honest X-Forwarded-Host, one literal true, and one deleted env line would have shown the hole in the first minute — the same minute we spent re-registering redirect URIs in the Google console.

If Auth.js loops to /login behind Coolify and the same sign-in works on localhost — book a free AI Potenzial-Check. The Server Actions CSRF writeup is the same proxy lying to a different check; the Auth.js JWT note is the reminder that a bounce to /login is a costume until the server string says whose.

acurio · Hallucinated citations? Not in your manuscript.

Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.

NEXT STEPWas this useful?