Part 2 · 29 August 2026
Two bugs deep: a Google sign-in that refused to work
Every automated check was green and the last item was me clicking a button. The click took two days, two stacked bugs, and taught me more than the rest of the milestone.
series · Rebuilding a school-ops dashboard
I’m rebuilding a school-ops dashboard as a self-hosted practice project: a NestJS API, a React app, and Postgres, all running in Docker behind a Caddy reverse proxy, with a library called Better Auth handling “Sign in with Google”. I’m building it by directing AI coding agents and reviewing what they produce, and part of the point of the project is to understand what I’m shipping rather than to ship as fast as possible.
The build had reached its first milestone: every automated check was green, and the last item was me, in a real browser, clicking “Sign in with Google”. I expected a two-minute victory lap. The click took two days and taught me more than the rest of the milestone combined, so I’m writing it down while it’s fresh: what broke, how we found each cause, and what I’d know to do next time.
Attempt one: Google says redirect_uri_mismatch
My first click never even reached my own app. Google showed an error page: Error 400: redirect_uri_mismatch.
I learned what this means: when my server sends someone to Google to sign in, it includes a “redirect URI”, the address Google should send the person back to afterwards. Google keeps a list of addresses I’ve approved in its console, and I found out that if the URI my server sends doesn’t match an entry on that list exactly, Google refuses. Not close, exact: http vs https, localhost vs 127.0.0.1, even a trailing slash makes it a different string in Google’s eyes. I had registered the URI for dev mode but not the one for the Docker setup, which runs on a different port.
Rather than guessing which URI my server was sending, I found the useful step was to ask the server itself:
curl -s -X POST http://localhost:8080/api/auth/sign-in/social \
-H "content-type: application/json" \
-d '{"provider":"google","callbackURL":"/"}' \
| grep -oE 'redirect_uri=[^&"]+'
Walking through it, because past-me would have copy-pasted this without knowing what it does:
curl -s -X POST http://localhost:8080/api/auth/sign-in/socialsends the same request my “Sign in with Google” button sends.-skeeps curl quiet,-X POSTmakes it a POST.- The
-Hline tells the server the body is JSON, and-dis the body itself: which provider I want (google) and where to land after sign-in. - The server replies with a long Google URL, the same one my browser would be redirected to. The
grep -oE 'redirect_uri=[^&"]+'part fishes theredirect_uri=parameter out of that URL and prints only that.
The output was redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fapi%2Fauth%2Fcallback%2Fgoogle, which is URL-encoding for http://localhost:8080/api/auth/callback/google. That exact string went into Google’s console, and attempt one was solved. Lesson banked: don’t guess what your server sends when you can make it tell you.
Attempt two: my own app says ?error=UNKNOWN
Google now accepted the sign-in and sent me back, and my own app dumped me on the homepage with ?error=UNKNOWN in the address bar. The browser had nothing more to tell me.
This is where I learned the habit that carried the whole debugging session: when the browser shows a vague error, the server’s log usually has the real one. My API runs in a Docker container, and Docker keeps everything the app prints. From the project folder:
docker compose logs api --since 10m
docker compose logs prints the stored output of a container, api names which container (mine are called postgres, api, web, and caddy), and --since 10m limits it to the last ten minutes so I’m not scrolling through hours of noise. In that output sat the real error:
ERROR [Better Auth]: State not found undefined
I didn’t know what “state” meant in this context, so I asked, and the explanation that stuck with me goes like this. When you click “sign in”, the server writes a random one-time code (the “state”) into its database and puts a matching cookie in your browser, then sends you to Google with the code attached. When Google returns you, the code comes back in the URL and the cookie comes back with your browser, and the server checks that they pair up. The point is safety: without it, an attacker could paste a forged “welcome back” link and get treated as someone mid-sign-in. “State not found” means the server couldn’t find its half of the pair.
I sketched the round trip to make it stick. ABC123 stands for the random state code:
MY BROWSER MY SERVER GOOGLE
| | |
|-- 1. click "sign in" --->| |
| | 2. saves ABC123 in |
| | its database |
|<- 3. cookie: ABC123 -----| |
|<- 4. "go to Google, take | |
| state=ABC123" ------| |
| | |
|-- 5. I sign in at Google; state=ABC123 rides along>|
| | |
|<- 6. "go back to /api/auth/callback/google |
| ?state=ABC123&code=XYZ" ---------------------|
| | |
|-- 7. callback request -->| |
| URL carries ABC123 | 8. finds ABC123 in the |
| cookie carries | database, checks it |
| ABC123 | matches the cookie |
| | |
|<- 9. signed in ----------| |
The error told me step 8 was failing: the server went to find its saved half of the pair and came up empty.
We checked the plausible suspects one at a time, and I noted how each check works so I could rerun them myself:
- Was the code being saved at all? The database is another container, so you can open a SQL prompt inside it:
docker compose exec postgres psql -U minim -d minimand thenselect * from verification order by created_at desc limit 5;. The rows were there, one per click, each valid for ten minutes. Saving worked. - Was the cookie being set? Rerunning the curl from attempt one with
-imakes it print the response headers, and there was theSet-Cookie: better-auth.state=...line with sensible settings. Cookies looked fine. - Were the clocks wrong? New one for me: on Docker Desktop for Windows, the Linux VM’s clock can drift after the host sleeps, and then codes can look expired the moment they’re created.
docker compose exec api date -uagainst my own clock showed a one-second difference. Not the problem, but a check I’m keeping.
The clue turned out to be in the log line itself. The message ends with the value the library looked up, and it looked up undefined. The code wasn’t mismatched or expired; the server-side handler never received one at all, even while a valid code sat in the database.
That suggested an experiment that settled things. Take a code I could see in the database, and hand-deliver it to the callback address myself:
curl "http://localhost:8080/api/auth/callback/google?state=CUUP8DTj...&code=fake"
This pretends to be Google sending me back: the state= parameter carries the real stored code, and code=fake stands in for the credential Google would include. If the server could read the URL properly, the log should at least show it found my state. Instead the log printed State not found undefined again. I passed the state by hand and the server still saw nothing, so something inside my own stack was stripping the query string (the ?state=...&code=... part of the URL) before the auth library saw it.
One more version of the same probe, run from inside the API container, took the reverse proxy off the suspect list: same result without Caddy in the path. That left the way the auth handler was mounted in Express:
app.use("/api/auth/*splat", toNodeHandler(auth));
As I understand it now: app.use mounts middleware, and Express rewrites the URL a middleware sees, cutting off the part of the path that matched. Somewhere in that rewrite, with this wildcard pattern, the query string was lost too. Sign-in requests still worked because their data travels in the POST body, and the callback broke because it’s the one request that carries its data in the URL. Better Auth’s own docs mount the handler with app.all(...) instead, which registers a route, and routes get the URL untouched:
const expressApp = app.getHttpAdapter().getInstance();
expressApp.all("/api/auth/*splat", toNodeHandler(auth));
One changed line. Rerunning the hand-delivered-state probe now logged the actual state value instead of undefined, which is how we knew the fix landed before I ever touched the browser.
Where the request was dying, drawn out:
Google sends my browser back to:
/api/auth/callback/google?state=ABC123&code=XYZ
|
v
+----------------------------+
| Caddy (reverse proxy) | full URL passed on OK
+----------------------------+
|
v
+----------------------------+
| Express: app.use(...) | URL rewritten on the way
| mount for Better Auth | through; the ?state=...
+----------------------------+ &code=... part LOST HERE <-- bug
|
v
+----------------------------+
| Better Auth callback | receives a URL with no
| handler | state at all:
+----------------------------+ "State not found undefined"
The probes gave me each row of this picture: the through-Caddy curl and the inside-the-container curl failing the same way is what moved the arrow from Caddy down to Express.
Attempt three: internal_server_error
Fresh click, new error in the address bar. Back to docker compose logs api, which had:
[BetterAuthError: The field "issuer" does not exist in the schema
for the model "account". Please update your schema.]
Translated: the running library expected my database’s account table to have a column named issuer, and it didn’t.
This one embarrassed the process more than the code, and it’s the part I most want to remember. Earlier in the build, one agent had read Better Auth’s docs, seen an issuer column listed, and included it. A later agent ran the library’s official schema generator, @better-auth/cli generate, which produced a table without issuer, concluded the docs were wrong, and removed the column. A reviewer re-ran the generator and agreed. Two independent checks, and the running app still failed.
The resolution came from asking which authority we should have trusted. The installed library was version 1.7.2, and reading its actual installed code showed it requires issuer (version 1.7 changed how accounts are identified). The generator, meanwhile, turned out to have no release newer than 1.4.22, and each release generates schema from its own bundled, older copy of the library. So “verify with the official tool” was quietly answering for a version from months ago, with no warning that it was doing so. We put issuer back with a proper migration, matching what the installed code asks for.
Attempt four: “You are Aaron, role manager.” Signed in.
The whole journey on one map, with where each attempt died:
BROWSER --> GOOGLE --> CADDY --> EXPRESS --> BETTER AUTH --> DATABASE --> signed in
^ ^ ^
| | |
Attempt 1 died Attempt 2 died Attempt 3 died
redirect URI not app.use mount dropped account table missing
on the approved ?state=...&code=... the issuer column
list from the URL
Attempt 4: all the way through.
Each fix moved the failure one stop further down the line, which I’ve since learned is what progress in debugging looks like: the error changing is good news, even when the new one is uglier.
The lessons I’m keeping
The browser error is a headline; the log is the story. ?error=UNKNOWN told me nothing. docker compose logs api --since 10m is now the first thing I type, and I know my four container names to point it at.
Log lines carry clues in their tails. The word undefined at the end of State not found undefined held the whole first diagnosis. I would have skimmed past it before this week.
When theories pile up, hand the system a known-good input and watch. Feeding a state I could see in the database to the callback, then repeating it from inside the container, replaced three theories with one culprit in two commands.
Frameworks treat middleware and routes differently. I didn’t know app.use rewrites the URL it passes along. Now I do, and I know the symptom looks like data vanishing between two components that are both “working”.
A code generator is only as current as the library it bundles. Docs said one thing, the official CLI said another, and the truth lived in the installed package’s source. Check the version of the tool against the version you run before trusting its output.
Green tests weren’t lying, but they weren’t testing this either. Our test setup mocks the auth library (it can’t load under the test runner), so both bugs sat invisible behind passing checks. The click that failed was the first honest test of the path, and the coverage gap now has a named line on the backlog instead of being a surprise.