The three-layer model works. Part 1 of this series is the invoice incident that proved it. Part 2 is the proxy.ts matcher gaps that fail silently. Part 3 is the mutation-layer auth check that gets skipped even when everything else looks solid. My kit implements this in Route Handlers rather than Server Actions, but the principle is identical: every mutation endpoint verifies the caller before touching data.
I built all three layers correctly. Then I tried to ship a complete auth kit on top of them.
Email and password login. Google and GitHub OAuth. Email verification. Password reset. Role-based routes. An admin panel with live database stats. A UI a real person would trust with a real password.
That is when the integration bugs appeared. Not architecture bugs. Wiring bugs. The kind that only exist once every piece is connected and real flows run through the whole thing.
Five of them. Here is exactly what happened.
Bug 1: The Cookie Write That Reported Success and Then Vanished
Login returned 200. Tokens in the response. Client wrote the cookie. The very next request redirected straight back to login.
I went to the JWT first. The JWT was fine. The proxy was fine. The cookie was simply not there on the next request.
Here is what was actually happening. After a successful login call, the client was writing the cookie with document.cookie = .... In certain browser and timing combinations on localhost, that write reported success. document.cookie.includes('auth_tokens') returned true immediately after the assignment. Then a few seconds later, gone. No error. No warning.
The fix was not about the timing. It was moving the write to where it belonged from the start.
// app/api/auth/login/route.ts
const COOKIE_NAME = process.env.NEXT_PUBLIC_AUTH_COOKIE_NAME ?? "auth_tokens";
// ... after building responseBody and cookieTokens ...
const response = NextResponse.json(responseBody, { status: 200 });
const isSecure =
request.headers.get("x-forwarded-proto") === "https" ||
request.nextUrl.protocol === "https:";
const maxAge = rememberMe === true ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7;
response.cookies.set(COOKIE_NAME, JSON.stringify(cookieTokens), {
httpOnly: true,
sameSite: "lax",
secure: isSecure,
path: "/",
maxAge,
});
return response;
httpOnly: true means no JavaScript on the page can read this cookie at all. A script injected through an XSS vulnerability still cannot steal the session token because the token is invisible to every script. The browser stores it automatically from the Set-Cookie response header. Nothing to silently vanish.
As Part 1 covered, the proxy reads the session from a cookie, not localStorage. httpOnly closes both issues at once: the proxy can see the cookie, and client-side scripts cannot touch it.
If the client is writing something the server should write, stop debugging the client write. Move it.
Bug 2: The Navigation That Never Reached the Proxy
Cookie fixed, server-set, httpOnly. Still redirecting back to login.
After the login API returned 200, the form called window.location.href. But before I landed on that fix I had tried router.push(redirectTo) first. That is where the second bug was hiding.
router.push is a client-side navigation. No real HTTP request fires. The browser has no reason to attach the newly set cookie to anything outgoing because nothing crossed the network. proxy.ts only sees real HTTP requests. A route change that stays inside React's router is invisible to it. This is the same boundary Part 2 covered when explaining why matcher gaps are invisible at the proxy level: the proxy cannot check what it never sees.
The login form uses window.location.href for exactly this reason:
// app/login/login-form.tsx
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(email.trim(), password, rememberMe);
window.location.href = redirectTo; // real HTTP GET, proxy sees the cookie
} catch (err) {
// ... error handling
setIsSubmitting(false);
}
}
window.location.href forces a full page reload. A genuine HTTP GET. The browser attaches every cookie it holds. The proxy sees it because this request actually crossed the network.
Bug 3: A Database Integer in the JWT Sub Claim
Cookie arriving correctly. Proxy still rejecting the token.
Debug logging showed the JWT decoding fine. Every claim present. The type guard in extractPayload kept failing on sub. typeof sub !== 'string' kept returning true even though the value looked like a user ID.
The database id column is SERIAL, a Postgres integer. When that integer got passed into jose's .setSubject() without an explicit cast, it was serialized into the token as a number. The extractPayload function checks typeof sub !== "string" and throws if that fails. It was doing exactly what it should. The claim was wrong because of an upstream assumption that never held for an integer primary key.
The fix is in lib/auth/jwt.ts on both signAccessToken and signRefreshToken:
// lib/auth/jwt.ts
export async function signAccessToken(
payload: SignablePayload,
): Promise<string> {
return (
new SignJWT({
role: payload.role,
email: payload.email,
permissions: payload.permissions,
})
.setProtectedHeader({ alg: "HS256" })
// Always cast to string here. Postgres ids that come through as
// numbers will otherwise get signed as numbers, and proxy.ts expects
// sub to always be a string when it verifies the token.
.setSubject(String(payload.sub))
.setIssuedAt()
.setExpirationTime(ACCESS_TOKEN_TTL)
.sign(ACCESS_SECRET)
);
}
String(payload.sub) on both signAccessToken and signRefreshToken. One word. The extractPayload type guard that catches this is correct. The claim was malformed, just not visibly, because a number and a number-as-string look identical in a console log.
This bug is invisible in development if test data uses string IDs. It only surfaces when a real SERIAL column meets a JWT library that validates claim types strictly.
Bug 4: OAuth Sign-In When the Same Email Already Has a Password Account
Email and password auth working end to end. Time to add Google and GitHub.
The OAuth flow itself is documented well enough. The question that actually needed answering: what happens when someone who already has a password account later clicks "Continue with Google" using the same email address?
The callback route handles three distinct cases:
// app/api/auth/google/callback/route.ts
// Case 1: they've signed in with Google before, google_id already set
let user = await db.queryOne<UserRow>(
`SELECT id, email, name, role, is_active, google_id
FROM authkit_test_users
WHERE google_id = $1`,
[googleUser.sub],
);
if (!user) {
// Case 2: no google_id match, but email matches an existing row
const existingByEmail = await db.queryOne<UserRow>(
`SELECT id, email, name, role, is_active, google_id
FROM authkit_test_users
WHERE email = $1`,
[normalizedEmail],
);
if (existingByEmail) {
// Linking is safe here because Google has already verified this
// email belongs to whoever is sitting at the browser right now.
// email_verified gets set true at the same time, in case they
// registered with a password earlier and never finished clicking
// their own verification link.
await db.query(
`UPDATE authkit_test_users
SET google_id = $1, email_verified = true
WHERE id = $2`,
[googleUser.sub, existingByEmail.id],
);
user = existingByEmail;
} else {
// Case 3: brand new person, create the row
// password_hash stays null, role always defaults to 'user'
const rows = await db.query<UserRow>(
`INSERT INTO authkit_test_users
(email, password_hash, name, role, is_active, email_verified, google_id)
VALUES ($1, NULL, $2, 'user', true, true, $3)
RETURNING id, email, name, role, is_active, google_id`,
[normalizedEmail, googleUser.name ?? null, googleUser.sub],
);
user = rows[0] ?? null;
}
}
The linking in Case 2 is safe specifically because of who verified the email. Google has already confirmed this email belongs to whoever is sitting at the browser. That is a stronger guarantee than most apps' own verification flow. The unsafe version of this pattern is linking accounts based on an email someone simply typed into a field with no third-party verification behind it.
Role always defaults to 'user' on new accounts. There is no code path a visitor can trigger that grants admin through a sign-in button.
GitHub needed one extra step Google does not. Google always returns a verified email with the basic profile request. GitHub does not. Many users set their email to private, so the profile response comes back with email: null. The fix is a second request to GitHub's emails endpoint, filtering for the address marked both primary and verified. No verified primary email means the account cannot sign in this way.
Both providers on the same sign-in screen. The three-case account lookup in Bug 4 is what makes this safe when the same email already exists from a password registration.
Bug 5: Forgot Password for an Account That Never Had a Password
This one does not show up until you have built both OAuth and email/password auth and put them in the same product.
A user registers with Google. Months later they land on the forgot password page and type in their email, because they have genuinely forgotten they always came in through the Google button and never set a password.
Without handling this, that person gets a password reset email for a password that has never existed. The link technically works. The experience makes no sense and they leave more confused than before.
The password_hash column stays null for every OAuth-only account. That is the check:
// app/api/auth/forgot-password/route.ts
if (!user.password_hash) {
// This account signed in through an OAuth provider and never set a
// password. Send a message pointing at whichever one they actually
// used rather than sending a reset link for a password that doesn't exist.
const provider = user.google_id
? "Google"
: user.github_id
? "GitHub"
: "Google or GitHub";
try {
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL ?? "noreply@example.com",
to: normalizedEmail,
subject: `Sign in with ${provider}`,
html: buildOAuthOnlyEmail({ name: user.name, provider }),
});
} catch {
// Email failed to send, the generic response goes out either way
}
return NextResponse.json(genericSuccessBody, { status: 200 });
}
The buildOAuthOnlyEmail function is in the same file. It generates a plain HTML email that tells the person their account was created with that provider and links them back to the sign-in page. No password reset link. No confusion.
The browser response is identical regardless of which branch runs:
const genericSuccessBody = {
message:
"If an account exists for this email, we've sent you instructions.",
};
Same message, same 200 status, whether the user has a password, uses OAuth, or does not exist at all. This endpoint should never confirm or deny which email addresses are in your system.
Same page, same response to the browser, for every email submitted. The branching on password_hash happens entirely on the server side.
What Shipped After These Five Bugs Were Gone
Registration with bcrypt hashing, a live password strength meter, and a terms checkbox. Email verification and password reset through Resend with separate token lifetimes: 24 hours for verification, 1 hour for reset. Google and GitHub OAuth with the account linking logic above. Role-based routes. An admin panel pulling live counts from the database. A 403 page instead of a crash when someone hits a route their role does not cover.
The data layer ownership pattern from Part 1 runs on every query. WHERE user_id = $1 inside the SQL, not as a wrapper around the result. The auth check pattern from Part 3 runs on every mutation endpoint. Verify the caller before touching data. Both are in the kit exactly as written in the earlier posts in this series.
Live database counts in the admin panel. Anyone without the admin role who hits this URL directly lands on the 403 page instead.
One Tailwind v4 issue worth naming because it produces zero errors. Next.js 16 on Turbopack has a documented file watching problem where Tailwind's class detection misses .tsx files entirely. Clean build, no warnings, completely unstyled HTML. The fix:
/* app/globals.css — add after @import "tailwindcss" */
@source "../app/**/*.{ts,tsx}";
@source "../components/**/*.{ts,tsx}";
@source "../lib/**/*.{ts,tsx}";
Three lines and everything renders correctly.
Three Things for the Auth Checklist After This Build
Parts 1 through 3 of this series built a checklist of things that work locally and break in production. Three more belong on it now.
Client-side cookie writes can silently vanish with no error. Move the write to the server with response.cookies.set(). That is where it belongs regardless of any timing issue.
router.push after login does not trigger the proxy. If the proxy needs to read a freshly set cookie on the next request, that request has to be a real HTTP GET. Use window.location.href.
Wrap integer primary keys in String() before signing a JWT. .setSubject(String(payload.sub)) not .setSubject(payload.sub). A SERIAL column is an integer. The JWT type guard that catches this is correct. The claim is malformed, just not visibly so.
Get the Auth Kit
Everything in this series built into one kit: three-layer security, email and password, Google and GitHub OAuth, email verification, password reset, role-based access, admin panel, and deployment guide. Next.js 16 Auth Kit at shubhra.dev
The full implementation tutorial with every code file, database schema, and deployment notes is at I Built a Next.js 16 Auth Kit. Every Edge Case I Found Is in Here.
Has anyone else hit the OAuth forgot-password edge case before it shipped? I started paying attention to it after this build. More common than I expected. People who register with Google genuinely try the forgot password page later because they forget how they originally signed in. Curious whether others caught it early or found out from a user.
Note: AI used for image editing.



Top comments (25)
Good debugging post as always! 😀
Authentication features are complicated, and even a small mistake can lead to serious security issues or sensitive data leaks. I'm always nervous when working on login-related features. 😅
Thanks! I feel the same way. Even a tiny mistake in auth can end up having much bigger consequences than you expect. It definitely made me slow down and test every flow more thoroughly. Appreciate the kind words.
I enjoyed reading this 😄
I haven't worked much with Next.js auth, but it's always interesting seeing the kinds of issues that only show up once everything is connected together. Thanks for sharing them!
Thanks, glad it was a fun read! Every piece passed on its own, but wiring everything together exposed bugs I never would have found by testing each piece in isolation. Appreciate you reading through it!
The forgot-password case for an OAuth-only account is one almost everyone ships without noticing, because your test users always remember how they signed up and real users genuinely don't. Sending the "you used Google" email instead of a dead reset link is the right call. The natural next step, if you haven't already, is letting those accounts set a password from their settings, so the person who's tired of the Google button has a way out that isn't resetting a password that never existed. Bug 2 was my favorite catch though, the router.push versus window.location thing burns people precisely because nothing errors, the cookie just silently never travels.
Thanks, that's a good catch. I haven't built that yet. The forgot-password flow handles the "you used Google" case correctly, but you're right, there's no actual path for that person to set a password and move off the provider. That's a good feature to add.
And yeah, the router.push thing was the worst kind of bug. Everything looks fine, no errors anywhere, the cookie just never makes the trip. It took me longer than I'd like to admit to figure out why.
Most auth systems don’t fail at the architecture level, they fail at the integration boundaries where browser behavior, timing, and state all interact in unpredictable ways.
Cookie issues like this are especially tricky because everything looks correct on the surface, until you trace what the browser actually persisted versus what the app assumed it persisted.
Yeah, the browser side of it is brutal. The cookie write returned true, document.cookie showed it right there, and then the next request just... didn't have it. No exception, no failed promise, just a silent gap between what the app assumed it stored and what the browser actually sent. Nothing looks broken when you check each piece alone, that's what made it slow to catch.
the JWT sub integer one costs the most debug time because the wrong tool gives you a false clear. reach for
jose.decodeJwt()during investigation, no secret required, quick to drop in for a spot check. it shows the claim just fine: number or string looks identical in a console.log, so you call the token good and go looking elsewhere for twenty minutes. rule we settled on: always debug JWT payloads through the same code path that runs in production, meaningjwtVerifywith the same options, not the convenience decode. otherwise you are testing different code from what is failing. does yourextractPayloadalso enforceissoraud? thesubtype guard caught this bug — curious if you gate on issuer too.decodeJwt got me exactly like that. Console looked clean, token seemed valid, so I burned twenty minutes checking the proxy and the database before realizing the verify path was failing on a type I couldn't even see. Right now extractPayload only gates on sub type and expiration, no iss or aud yet.
What ties all five together is that they're the same bug in five costumes: a success signal that was true on your side of a boundary and false on the other side. The cookie write returned true, then wasn't there next request. router.push "navigated," but no HTTP request fired. The JWT signed cleanly, but sub crossed as a number. Every one reported success locally and failed at the handoff.
The reusable version I've landed on: for anything crossing a boundary — client→server, JS value→signed token, write→next-read — the assertion has to live on the far side. You verified the write; the thing that mattered was the read. document.cookie telling you it's there is grading your own homework; the real check is "did the next request carry it."
The router.push one is the nastiest because there's no boundary crossing at all — the failure is that the network trip you assumed happened just didn't, and nothing errors because nothing ran. Great write-up. "Passed every test, broke when wired together" is exactly the class of bug unit tests structurally can't see, because the seam isn't inside any one unit.
That's exactly the pattern. I was verifying the write, not the read, every time. The router.push one is the worst because there's no error at all. Just a missing network trip that everything downstream assumed happened. Good catch.
"A missing network trip that everything downstream assumed happened" — that generalizes way past auth, too. Optimistic UI updates, fire-and-forget analytics, anything that'll "sync later": same shape, no error, just a quiet gap between what you think happened and what actually did. Really enjoyed the series — "each part passed alone, the seams failed" is the lesson most of us end up learning the expensive way.
This is a very practical reminder that passing tests at the unit or layer level does not guarantee the full auth flow is safe. The cookie-write and router.push issues are especially good examples of bugs that only exist at the browser/server boundary. I like the pattern of moving security-sensitive behavior back to the server and making every mutation verify the caller before touching data. For production frontend platforms, these integration-edge cases deserve their own checklist because they rarely show up in isolated tests.
Thanks! That ended up being my biggest takeaway too. Every layer passed its own tests, but the real bugs showed up where state crossed from one layer to another. I've started actually testing those boundaries now, not just assuming they'll work because each piece works on its own. Appreciate you reading through it.
Great read — it's a good reminder that even with solid unit tests, integration is where the real pressure tests happen. I've seen similar issues when building secure enclaves with VoltageGPU; the layers work in isolation, but data flow across components can expose subtle timing or permission issues. Always value end-to-end testing in realistic scenarios.
Thanks! That was the biggest takeaway for me too. Every piece worked on its own, but the full flow exposed bugs I never would have found by testing each part in isolation. It sounds like you've run into the same kind of integration problems, just in a different context.
This is the most honest bug-report title I've seen this week. Passing every unit test and breaking at integration is the exact gap that matters, because tests verify the pieces and say nothing about the seams. It's also why AI-generated code is deceptive here: each function looks correct in isolation, so the tests go green, and the bug lives in the assumption between two of them that no test exercised. Green tests prove the parts work. They don't prove the system does.
That title came from staring at green tests while the login flow was still redirecting back to itself. Only noticed the AI-code angle after untangling this one. Every function tests fine alone, so you assume the system's fine too. But nobody writes a test for "did the cookie survive the handoff from client write to server read," and that's exactly where this one was hiding.
The individual parts were fine, it was the gaps that got me” is the realest line in here. Every layer passed its own test and the system still failed, because the failure didn’t live in any one layer. It lived in the seam where they hand off.
The JWT sub bug is the cleanest example of it. The type guard was correct, the claim was malformed, and a number vs a number-as-string look identical in a console log. Surface reads valid, the layer underneath disagrees, nothing errors. Those are the ones that eat a full day.
On the OAuth forgot-password case hit a version of that myself. The fix that aged best was treating password_hash null as the source of truth instead of inferring the provider from UI state. Solid series.
That line came from a real afternoon of staring at logs that all looked fine. The JWT sub bug was exactly that. console.log showed what looked like a perfectly valid user ID, the type guard was doing its job, and the mismatch turned out to be the claim type instead. Those are the bugs that take the longest to track down because every layer looks innocent on its own.
And you were right about password_hash. It ended up being the simplest source of truth instead of relying on UI state to infer anything. Glad to hear that approach held up well for you.
Thanks for the kind words on the series. It means a lot coming from someone who's clearly run into the same kinds of integration bugs.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.