</>CodeWithKarani

Multi-tenant next-auth: fixing NEXTAUTH_URL without disabling host checks

Karani GeoffreyKarani Geoffrey7 min read

You build a SaaS that serves each customer on their own domain: acme.app, globex.app, a handful of custom domains on top. Auth works perfectly in development, on one domain. Then a user on the second domain signs in with Google, and after the provider bounces them back they land on the first domain, logged in to the wrong place, or staring at a callback error. Every login on every tenant except one is subtly wrong.

The culprit is a single line of configuration. NEXTAUTH_URL is read once when the server starts and baked in as the canonical origin for the whole process. One deployment, one URL, many domains: the maths does not work. The advice you will find in the GitHub thread is to relax or disable the host checks so the library just uses whatever host the request arrives with. That makes multi-domain "work" and quietly hands an attacker the ability to point your OAuth callback at their own server. This is how to fix it without opening that door.

NEXTAUTH_URL (v4) / AUTH_URL (v5) is fixed at boot, so a single deployment cannot have a correct URL for every tenant domain. Do not blanket-disable host validation. Instead: run behind a reverse proxy you control, enable trustHost so Auth.js derives the origin from a trusted forwarded host, and gate it with an explicit allowlist of known tenant domains in the redirect and signIn callbacks. Register a callback URL per tenant with each OAuth provider. If tenants must be fully isolated, run one deployment per tenant with its own NEXTAUTH_URL.

Why one NEXTAUTH_URL cannot serve many domains

Auth.js needs to know its own absolute origin for two things: building the redirect_uri it sends to an OAuth provider, and deciding where to send the user after sign-in. By default it takes that origin from NEXTAUTH_URL, an environment variable, evaluated once at process start. It does not look at the incoming request. So if the variable says https://acme.app and a request arrives for globex.app, the callback URL handed to Google still says acme.app, and the post-login redirect goes there too.

This is not a bug. A fixed, server-controlled origin is a security feature: it means the callback URL cannot be influenced by whatever host header a client sends. The moment you make the origin depend on the request, you inherit responsibility for validating that request, and that is the whole difficulty of multi-tenant auth.

Request host: globex.app Single deployment NEXTAUTH_URL = https://acme.app builds callback for acme.app (wrong) OAuth provider redirect to acme.app
A single hardcoded origin cannot describe two domains. The request host is ignored, so the callback is built for the wrong tenant.

The dangerous workaround, named

The fix that circulates is some variant of "trust the host header unconditionally", or patching the library to skip its origin check so it always uses the incoming host. In Auth.js v5 the honest version of this knob is trustHost: true (or the AUTH_TRUST_HOST environment variable). Set carelessly, it tells Auth.js to build the callback URL from whatever Host or X-Forwarded-Host the request carries.

If any untrusted request can reach your app, that is a host-header injection vulnerability. An attacker sends a login request with Host: attacker.com, your app dutifully builds a redirect_uri pointing at attacker.com, and depending on your provider config the OAuth flow (and the auth code or token in it) can be steered to a server they control. The host check exists precisely to stop this. Turning it off to fix a redirect bug trades a cosmetic problem for account takeover.

The safe pattern, in numbered steps

Step 1: Put a reverse proxy you control in front

trustHost is only safe if the host it trusts cannot be forged. That means a proxy that sets a forwarded host you control and strips client-supplied host headers, so by the time a request reaches the app, its host is trustworthy. If you run on a platform that already does this (Vercel sets it for you), Auth.js trusts the host there automatically. On your own nginx reverse proxy, set it explicitly:

location / {
    proxy_pass http://app_upstream;
    proxy_set_header Host              $host;        # the real requested host
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Proto https;
}

Step 2: Enable trustHost, but validate against an allowlist

Enabling trustHost lets Auth.js derive the origin per request. On its own that is still too trusting, so add an explicit list of the domains you actually serve and reject anything else. Keep the allowlist in configuration, not derived from the request:

// auth.ts (Auth.js v5)
const TENANT_HOSTS = new Set([
  "acme.app",
  "globex.app",
  "portal.initech.com",
])

export const { handlers, auth } = NextAuth({
  trustHost: true,
  callbacks: {
    async signIn({ /* ... */ }) {
      // gate sign-in on a host you recognise
      return true
    },
    async redirect({ url, baseUrl }) {
      const target = new URL(url, baseUrl)
      if (TENANT_HOSTS.has(target.host)) return target.toString()
      return baseUrl   // anything unknown falls back to the safe origin
    },
  },
})

The redirect callback is your last line of defence against an open redirect: only URLs whose host is an approved tenant get returned; everything else collapses to baseUrl. This is the same discipline behind fixing Auth.js redirects behind a reverse proxy, applied to many domains at once.

Step 3: Register a callback URL per tenant with the provider

No app-side setting changes this. OAuth providers match redirect_uri exactly against a registered allowlist, so each domain needs its own entry in the provider's console:

https://acme.app/api/auth/callback/google
https://globex.app/api/auth/callback/google
https://portal.initech.com/api/auth/callback/google

If a tenant domain is missing here, that tenant's login fails at the provider with a redirect-uri-mismatch error, no matter how correct your app config is. For custom customer domains, this is the step that has to be automated as part of onboarding.

Step 4: If tenants must be isolated, deploy per tenant

For a smaller set of high-value tenants, the cleanest answer is not to share a deployment at all. Run one instance per tenant, each with its own NEXTAUTH_URL. You avoid trustHost entirely, the origin is fixed and unforgeable again, and a misconfiguration on one tenant cannot leak into another. The cost is more deployments to operate; the benefit is that the hardest security question disappears.

Verification

Prove each tenant builds its own callback URL. Sign in from the second domain and inspect the request to the provider:

# the authorize request should carry the tenant's own redirect_uri
# redirect_uri=https%3A%2F%2Fglobex.app%2Fapi%2Fauth%2Fcallback%2Fgoogle

Then prove the host check still bites. Send a forged host and confirm the app does not build a callback for it:

curl -s -H "Host: attacker.com" https://globex.app/api/auth/signin \
  | grep -o 'callback/[a-z]*' | head -1
# should reflect a known provider path on an allowlisted host, never attacker.com

If a forged host produces a callback pointing at the attacker's domain, trustHost is enabled without a real trusted proxy in front, and you have the vulnerability, not the fix.

What people get wrong

The headline mistake is treating trustHost as a convenience flag. It is a statement that the host header can be believed, and that is only true when something upstream guarantees it. Enabling it on an app that is directly reachable, or behind a proxy that forwards the raw client host, is the exact configuration that host-header injection exploits.

The second is skipping the redirect callback. Auth.js will happily send a user to an absolute URL after login if you let it, and a login endpoint that redirects anywhere is an open redirect, the workhorse of phishing. Allowlisting the redirect target is not optional in a multi-domain app.

The third is assuming the app config can replace the provider's callback registration. It cannot. The redirect_uri allowlist lives with the identity provider, and every domain has to be on it. Automating that registration is part of building real multi-tenant auth, not an afterthought.

When it is still broken

  • Redirects still go to the wrong domain. Confirm the proxy actually forwards the requested host. If X-Forwarded-Host is missing or set to a fixed backend name, Auth.js sees the wrong host even with trustHost on.
  • Cookies do not persist across the flow. Session cookies are scoped to a domain. A cookie set on acme.app is not sent on globex.app; each tenant domain gets its own cookie, which is correct. Do not try to share one cookie across unrelated domains.
  • redirect_uri_mismatch from the provider. The tenant's exact callback URL is not registered, or differs by scheme or a trailing component. Match it character for character in the provider console.
  • Custom domains break on TLS. A tenant on their own domain needs a certificate that covers it. If the proxy terminates TLS, make sure the cert (or automatic issuance) includes every custom domain, or the request never reaches your auth logic at all.

Multi-tenant auth is one of those areas where the easy fix and the correct fix point in opposite directions. The easy fix removes a check; the correct fix keeps the check and teaches it which hosts are yours. Spend the extra hour on the allowlist and the proxy, because the failure mode of getting it wrong is not a broken login, it is someone else's login landing in an attacker's hands.

Frequently asked questions

Can NEXTAUTH_URL be different per request?
Not by itself. NEXTAUTH_URL (and AUTH_URL in Auth.js v5) is an environment variable read once when the process boots, so a single deployment serving many domains has one hardcoded value. To vary the URL per host you must either run one deployment per tenant, each with its own value, or enable trustHost and derive the URL from a validated forwarded host header.
Is setting trustHost: true safe for multi-tenant apps?
Only behind a reverse proxy you control that sets a trustworthy forwarded host and strips client-supplied host headers. trustHost tells Auth.js to believe the host header for building callback URLs, so if a raw untrusted request can reach it, an attacker can inject their own host and hijack the OAuth callback. Pair it with an explicit allowlist of tenant domains.
Do I need a separate OAuth callback URL for each tenant domain?
Yes. OAuth providers match the redirect_uri exactly against a registered list, so every tenant domain needs its own callback URL registered with the identity provider, for example https://tenant-a.example.com/api/auth/callback/google. No app-side configuration removes this requirement.
How do I stop an open redirect in the next-auth redirect callback?
Implement the redirect callback and only return URLs whose host is in your allowlist of known tenant domains; for anything else, return the safe baseUrl. Never return an attacker-supplied absolute URL unchecked, or a login flow becomes an open redirect that phishers can abuse.
#next-auth#Auth.js#Multi-tenant#Next.js#Host Header
Keep reading

Related articles