🪟 Windows TippsSeptemberaktion: Office 2024 für 28 Euro & Win 11 ab 10 Euro(17.09.2026 um 13:28 Uhr)
🪟 Windows ServerAuch Druckerprobleme nach September-Updates - Swiss IT Magazine(17.09.2026 um 16:32 Uhr)
🪟 Windows ServerWindows Update sperrt Domänen-Nutzer aus | Nau.ch(17.09.2026 um 16:36 Uhr)
🪟 Windows ServerKB5124008 Domain Trust Fehler: Ursache und Fix - WindowsPower.de(17.09.2026 um 17:18 Uhr)
🪟 Windows TippsSeptemberaktion: Office 2024 für 28 Euro & Win 11 ab 10 Euro(17.09.2026 um 13:28 Uhr)
🪟 Windows ServerAuch Druckerprobleme nach September-Updates - Swiss IT Magazine(17.09.2026 um 16:32 Uhr)
🪟 Windows ServerWindows Update sperrt Domänen-Nutzer aus | Nau.ch(17.09.2026 um 16:36 Uhr)
🪟 Windows ServerKB5124008 Domain Trust Fehler: Ursache und Fix - WindowsPower.de(17.09.2026 um 17:18 Uhr)
🔧 Programmierung 🕛 vor 2 Monaten 12 Min Lesezeit
0

My Next.js 16 Auth Passed Every Test. Five Bugs That Only Showed Up When I Wired It Together.

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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.




CODE
// 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:




CODE
// 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:




CODE
// 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:




CODE
// 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.





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.





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.

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Avision AD7100 & AD7100N - Dreifach kontrolliert gegen Doppelblätter und Papierstau
1 Quelle
Windows Server 2022: Mainstream-Support endet am 13. Oktober - ad-hoc-news.de
1 Quelle
Lenovo ThinkAgile VX850 V4: Neue Infrastruktur für KI und Virtualisierung - ad-hoc-news.de
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten My Next.js 16 Auth Passed Every Test. Five Bugs That Only Showed Up When I Wired It Together.

Thematisch verwandte Begriffe: Nextjs, Auth, Passed, Every · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...