·7 min read

The Auth Bug That Wasted an Hour of My Life

Next.jsAuth.jsDebugging

Debugging Auth.js v5's Silent Failures on Next.js 16

A generic error message, a beta framework, and the hour-long hunt through node_modules that ended with a three-line fix.


I was wiring up GitHub and Google OAuth on a new Next.js 16 app using Auth.js v5 — the only auth library that supports the App Router. The setup was textbook. I followed the docs, configured the providers, deployed to Vercel, clicked "Sign in with GitHub," and got:

"Server configuration error. Check server logs for details."

No stack trace. No hint about what was misconfigured. Just a redirect to my error page with ?error=Configuration. This error persisted through every fix I attempted for the next hour — schema rewrites, environment variable changes, middleware toggles, adapter swaps. The error message never changed. It was always "Configuration."

The Setup

The stack: Next.js 16.1.6, next-auth@5.0.0-beta.30, Turso (libSQL) with Drizzle ORM, deployed on Vercel. The auth config was standard:

TypeScript
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import { DrizzleAdapter } from '@auth/drizzle-adapter'

export const { handlers, auth, signIn, signOut } = NextAuth({
	basePath: '/api/auth',
	adapter: DrizzleAdapter(db, { /* table mappings */ }),
	providers: [Google(), GitHub()],
})

The login page used server actions — the pattern Auth.js's own documentation recommends:

TSX
<form action={async () => {
	'use server'
	await signIn('github', { redirectTo: '/' })
}}>
	<button>Sign in with GitHub</button>
</form>

The Wrong Turns

The "Configuration" error is Auth.js's catch-all. Internally, any error that isn't explicitly tagged as client-safe gets its type replaced with "Configuration" before the redirect. This means a dozen different failure modes — missing secrets, untrusted hosts, bad callback URLs, adapter errors, provider misconfiguration — all produce the same message.

I systematically worked through every possibility:

  • Missing environment variables? Created a diagnostic endpoint. AUTH_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET — all present. trustHost was true in development.
  • Database schema mismatch? Auth.js's DrizzleAdapter requires specific table structures that differ from what many tutorials show. The accounts table needs a composite primary key on (provider, providerAccountId) — not a separate id column. The sessions table needs sessionToken as the primary key. I rewrote the entire schema to match. Still "Configuration."
  • Adapter broken? I tested getUserByEmail directly through my diagnostic endpoint. It returned null correctly. The adapter was fine.
  • Middleware interference? I disabled it entirely. No change.
  • Provider configuration? I stripped down to a single GitHub provider with no adapter, no middleware, no callbacks. The absolute minimum config. Still "Configuration."

That last one was the turning point. When your minimal reproduction is NextAuth({ basePath: '/api/auth', providers: [GitHub] }) and it still fails, the problem isn't in your config. It's in the framework.

Reading the Source

I opened node_modules/@auth/core/index.js and traced the Auth() function line by line. The "Configuration" error can originate from two places:

  1. assertConfig() fails — returns a config validation error before the request is even processed
  2. AuthInternal() throws — the request is processed but something goes wrong, the error is caught, and if it's not a known client error, the type is silently replaced with "Configuration"
JavaScript
// @auth/core/index.js — the catch block
catch (e) {
	const isClientSafeErrorType = isClientError(error)
	const type = isClientSafeErrorType ? error.type : "Configuration"
	// redirect to error page
}

To figure out which path my error was taking, I built a diagnostic endpoint that called Auth() directly — the same way the signIn server action does:

TypeScript
const postReq = new Request(signInURL, {
	method: 'POST',
	headers,
	body: new URLSearchParams({ callbackUrl: '/' }),
})
const res = await Auth(postReq, { ...config, raw, skipCSRFCheck })

It worked. The response contained a redirect to GitHub's OAuth authorization page. Auth.js itself was fine.

Testing Each Layer

If Auth() works when called directly, the bug had to be in how the request reaches it. I tested the HTTP handler with curl:

Bash
# Get a CSRF token and its cookie
curl -c cookies.txt https://localhost:3002/api/auth/csrf

# POST to the signin endpoint with the cookie
curl -b cookies.txt -X POST \
	-d "csrfToken=TOKEN&callbackUrl=/" \
	https://localhost:3002/api/auth/signin/github

302 redirect to GitHub. The HTTP handler worked too.

So the route handler worked. Auth() worked. But clicking the button on the login page didn't. The only difference was the server action.

The Root Cause

The signIn server action in next-auth/lib/actions.js doesn't just call Auth(). It first constructs a URL using createActionURL(), then builds a Request object and passes it to Auth() directly — bypassing the HTTP handler entirely:

JavaScript
const signInURL = createActionURL(
	"signin",
	headers.get("x-forwarded-proto"),
	headers,
	process.env,
	config
)
let url = `${signInURL}/${provider}?${new URLSearchParams(authorizationParams)}`
const req = new Request(url, { method: "POST", headers, body })
const res = await Auth(req, { ...config, raw, skipCSRFCheck })

Something in this synthetic request construction breaks. In local development, one likely culprit is the protocol fallback in createActionURL:

JavaScript
const detectedProtocol =
	headers.get("x-forwarded-proto") ?? protocol ?? "https"

When x-forwarded-proto is absent from server action headers, the function falls back to "https", constructing https://localhost:3002/... for an HTTP server. Auth.js sees the https: protocol, enables secure cookies, and the resulting cookie mismatch causes failures.

But the bug also reproduced on Vercel — where the protocol is HTTPS and that fallback would produce the correct URL. So the protocol mismatch doesn't explain everything. The server action path differs from the HTTP handler in several ways: how headers are gathered via nextHeaders(), how cookies are forwarded, how AUTH_URL and NEXTAUTH_URL environment variables interact with the explicit basePath config during URL construction, and how the config object is spread ({ ...config, raw, skipCSRFCheck }). Any of these could introduce a mismatch between what Auth() expects and what it receives.

We didn't pin down the exact Vercel-specific mechanism because once we found a working fix, shipping it was more valuable than continuing the forensics. What we did prove conclusively: the HTTP handler works (tested via curl with cookie jars), Auth() works when called directly (tested via a diagnostic endpoint), and the server action signIn() does not. The bug lives somewhere in the gap between those last two.

The Fix

Stop using the signIn server action. Use direct HTML form POST — the same pattern Auth.js's own built-in sign-in page uses when you visit /api/auth/signin:

TSX
'use client'

function SignInButtons() {
	const [csrfToken, setCsrfToken] = useState('')

	useEffect(() => {
		fetch('/api/auth/csrf')
			.then(res => res.json())
			.then(data => setCsrfToken(data.csrfToken))
	}, [])

	return (
		<form action="/api/auth/signin/github" method="POST">
			<input type="hidden" name="csrfToken" value={csrfToken} />
			<input type="hidden" name="callbackUrl" value="/" />
			<button type="submit">Sign in with GitHub</button>
		</form>
	)
}

The client component fetches the CSRF token from the browser — so the token matches the cookie the browser actually has. The form POSTs directly to the Auth.js HTTP handler — which uses the real request URL, not a synthesized one. No protocol mismatch. No cookie confusion.

Takeaways

Don't trust generic error messages. Auth.js's "Configuration" error covers at least ten distinct failure modes. The error message is designed for end users, not developers. When you see it, the real error is somewhere in the catch block, silently rewritten.

Test each layer independently. The breakthrough came from testing with curl. The HTTP handler worked. Auth() worked. The server action didn't. That immediately localized the problem to the server action's request construction — a much smaller surface area than "auth is broken."

Beta frameworks compound debugging difficulty. Auth.js v5 is the only option for Next.js App Router — v4 doesn't support Server Components. But beta means the integration between next-auth and the latest Next.js version hasn't been fully hardened. The server action's request construction has subtle failure modes that broader testing across deployment targets would likely have surfaced.

Read node_modules. Documentation describes the happy path. When you're debugging a framework integration failure, the source code is the only reliable reference. Auth.js's error handling — where errors are caught, how they're classified, what gets exposed vs. swallowed — is only visible in the source.

The documented pattern isn't always the right pattern. The Auth.js v5 sign-in documentation recommends server actions as the primary approach. But Auth.js's own built-in sign-in page — the one it renders at /api/auth/signin — uses direct form POST. When the recommended approach doesn't work, look at what the framework does internally and copy that instead.

I've filed an issue with a minimal reproduction repo. Hopefully it saves someone else the hour.