Here's a scenario that actually happened to a developer recently: users were getting logged out of their app randomly. No errors, no complaints about passwords — just silent, mysterious logouts.
The culprit? Their /logout route was a simple GET endpoint. And Next.js was prefetching it.
The Problem in 30 Seconds
Next.js automatically prefetches links that appear in the viewport. It's a performance optimization — when a user hovers near a link or it scrolls into view, the framework loads that page in the background so navigation feels instant.
Great for your /about page. Catastrophic for your /logout page.
If your logout is a GET endpoint, the prefetcher hits it just by the link being visible. The user's session is destroyed before they ever clicked anything. They navigate to another page and — surprise — they're logged out.
But the real issue goes deeper than accidental logouts.
What Is CSRF and Why Does It Matter Here?
Cross-Site Request Forgery (CSRF) is a web attack where a malicious site tricks a user's browser into making requests to a site where they're already authenticated. The browser automatically includes cookies, so the request looks legitimate.
Here's how a GET-based logout becomes exploitable:
<!-- On an attacker's site -->
<img src="https://yourapp.com/logout" />
That's it. A single invisible image tag. When any logged-in user visits the attacker's page, their browser fetches that URL, sends along the session cookie, and logs them out. No click required. No JavaScript needed.
Now imagine the same pattern applied to something more dangerous — changing an email address, transferring funds, deleting an account. GET-based mutations are the root cause.
The HTTP Spec Already Told Us This
This isn't a new discovery. RFC 7231 defines GET as a safe method — it should only retrieve data, never cause side effects. Logout destroys a session. That's a side effect. GET is the wrong verb.
The rule is simple:
- GET = read data (safe, idempotent, cacheable)
- POST = change state (mutations, side effects, actions)
Logout changes state. It should be POST. Always.
Why This Bites Harder in Next.js
Next.js makes this problem worse in several ways:
1. Aggressive Prefetching
The <Link> component prefetches routes by default. Even with prefetch={false}, the behavior can be unpredictable during client-side navigation. A GET logout route is a ticking time bomb in any Next.js app.
2. Client-Side Routing Blurs the Lines
In a traditional server-rendered app, you might get away with a GET /logout because navigation is more intentional. In Next.js, the client-side router, route groups, layouts, and parallel routes create multiple opportunities for routes to be loaded unexpectedly.
3. Crawlers and Bots
If your logout URL appears anywhere in your HTML — nav bars, dropdowns, mobile menus — search engine crawlers will follow it. Googlebot doesn't care about your session, but link previews in Slack, Discord, or social media might trigger the endpoint too.
The Right Way to Handle Logout in Next.js
Option 1: Server Action (App Router — Recommended)
// app/components/LogoutButton.tsx
'use client'
import { logout } from '@/app/actions/auth'
export function LogoutButton() {
return (
<form action={logout}>
<button type="submit">Sign Out</button>
</form>
)
}
// app/actions/auth.ts
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function logout() {
const cookieStore = await cookies()
cookieStore.delete('session')
redirect('/login')
}
Server Actions are POST by default. No prefetching risk. No CSRF via image tags. This is the cleanest approach in modern Next.js.
Option 2: API Route
// app/api/auth/logout/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function POST() {
const cookieStore = await cookies()
cookieStore.delete('session')
return NextResponse.json({ success: true })
}
// Client component
async function handleLogout() {
await fetch('/api/auth/logout', { method: 'POST' })
window.location.href = '/login'
}
Explicitly POST. Clear intent. Works with any frontend pattern.
Option 3: Form With method="post" (Pages Router)
<form method="post" action="/api/auth/logout">
<button type="submit">Sign Out</button>
</form>
Old school but bulletproof. HTML forms with method="post" cannot be triggered by prefetchers, crawlers, or image tags.
The Broader Lesson: Never Mutate on GET
Logout is just the most common example. The same principle applies to:
- Unsubscribe links —
/[email protected]as GET means email previewers can unsubscribe people - Delete actions —
/delete-account?confirm=trueas GET is asking for trouble - Status changes —
/approve?id=123as GET means anyone with the URL can trigger it - One-click actions — Any "click to confirm" flow using GET is vulnerable
Every state-changing operation should use POST (or PUT/DELETE where appropriate), protected by CSRF tokens or same-origin checks.
Quick Security Checklist
Before you ship, verify:
- Logout uses POST, not GET
- No state-changing endpoints respond to GET requests
<Link>components never point to mutation endpoints- API routes that mutate data check the HTTP method
- CSRF tokens are used for sensitive form submissions
SameSitecookie attribute is set toLaxorStrict
It's a Five-Minute Fix
If you have a GET-based logout in production right now, converting it to a Server Action or POST API route takes minutes. The security improvement is significant.
The frameworks are pushing us in the right direction — Next.js Server Actions, Remix actions, SvelteKit form actions all default to POST. The pattern is clear: reads are GET, writes are POST. Follow it, and an entire category of bugs and vulnerabilities disappears.