CSRF protection is the set of server-side checks that prove a state-changing request came from your own site and not from a page an attacker controls. In practice it is one of three things: a synchronizer token, a double-submit cookie, or an Origin header check — with SameSite cookies underneath as a second layer.
CSRF itself is not dead, it has just moved. Browsers now ship default SameSite behavior that kills the classic cross-site form POST, which is exactly why teams stop thinking about it. Then a cookie gets set to SameSite=None to make an OAuth redirect work, or a GET route starts mutating state, or a forgotten subdomain gets taken over — and the hole is back.
This guide covers how the attack works, five protection methods with code, a full SameSite deep dive, framework setup for Next.js, Express, Django, Rails and Laravel, whether SPAs and APIs need CSRF protection at all, and the mistakes that leave apps exposed. The canonical reference throughout is the OWASP CSRF Prevention Cheat Sheet.
Cross-Site Request Forgery (CSRF) is an attack that tricks an authenticated user's browser into sending an unwanted request to a web application where they are logged in. The attacker does not need to steal the user's credentials — the browser sends them automatically.
Here is the core problem: browsers attach cookies to every request made to a domain, regardless of where the request originates. If you are logged into your bank at bank.com, and you visit a malicious page, that page can submit a form to bank.com/transfer — and your browser will include your session cookie with the request.
Imagine you are logged into your banking app. Your session is stored in a cookie. You then visit a page controlled by an attacker. That page contains this hidden form:
<!-- On attacker's page: evil.com -->
<form action="https://bank.com/api/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="attacker-account" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.getElementById('csrf-form').submit();</script>
When the page loads, the form auto-submits. Your browser sends a POST request to bank.com/api/transfer with your session cookie attached. The bank's server sees a valid session and processes the transfer. You never clicked anything — the attacker's page did it for you.
This works because the bank's server cannot distinguish between a legitimate request from its own frontend and a forged request from a malicious site. Both carry the same cookies.
The attack follows a predictable pattern:
CSRF attacks target state-changing operations: form submissions, API calls that modify data, account settings changes, and financial transactions. They do not give the attacker access to the response — they just trigger the action.
The synchronizer token pattern is the most robust protection and OWASP's primary recommendation. The server generates a unique, unpredictable token, stores it server-side against the session, and embeds it in forms. When the form comes back, the server compares the submitted token to the stored one. An attacker's page cannot read the token out of your HTML (the same-origin policy blocks that), so forged requests fail the comparison.
How it works:
// Server: Generate and store a CSRF token
import crypto from 'crypto';
function generateCsrfToken(session) {
const token = crypto.randomBytes(32).toString('hex');
session.csrfToken = token;
return token;
}
<!-- Embed the token in every form -->
<form action="/api/transfer" method="POST">
<input type="hidden" name="_csrf" value="a1b2c3d4e5f6..." />
<input type="text" name="to" />
<input type="number" name="amount" />
<button type="submit">Transfer</button>
</form>
// Server: Verify the token on submission
function verifyCsrfToken(req, session) {
const token = req.body._csrf || req.headers['x-csrf-token'];
if (!token || token !== session.csrfToken) {
throw new Error('CSRF token validation failed');
}
}
In Next.js Server Actions, CSRF protection is built in. Server Actions automatically verify that the request originates from your application by checking the Origin header against the Host header. But if you are using API Route Handlers (app/api/), you need to implement CSRF protection manually:
// app/api/transfer/route.ts — Manual CSRF check for API routes
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const origin = req.headers.get('origin');
const host = req.headers.get('host');
// Verify the request origin matches your domain
if (!origin || !origin.includes(host!)) {
return NextResponse.json(
{ error: 'CSRF validation failed' },
{ status: 403 }
);
}
// Process the request...
}
Strengths: Works regardless of cookie configuration. The gold standard for CSRF prevention.
Limitations: Requires server-side state (or a signed token variant). Must be included in every form and AJAX request.
The SameSite attribute on cookies controls whether the browser sends them with cross-site requests. This is the browser's built-in CSRF defense.
// Setting SameSite on a session cookie
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax', // or 'Strict'
maxAge: 3600000,
});
In one line: Strict never sends the cookie cross-site, Lax sends it only on top-level navigations, and None always sends it and requires Secure. The deep dive further down covers the trade-offs and the gaps in each.
Strengths: Zero application code. Chrome applies Lax even when you forget to set the attribute.
Limitations: Does not protect GET endpoints that change state, does not stop attacks launched from your own subdomains, and the default is one browser's current behavior rather than a guarantee. A layer, never the only defense.
Browsers enforce CORS preflight checks for requests with custom headers. A cross-origin request with a non-standard header like X-Requested-With triggers a preflight OPTIONS request. If your server does not respond with the appropriate CORS headers, the browser blocks the request entirely.
// Client: Add a custom header to every API request
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'checkvibe-app', // Custom header
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
// Server: Reject requests without the custom header
function requireCustomHeader(req, res, next) {
if (req.headers['x-requested-with'] !== 'checkvibe-app') {
return res.status(403).json({ error: 'Missing required header' });
}
next();
}
Why this works: An attacker's form submission cannot set custom headers. A cross-origin fetch with a custom header triggers a CORS preflight, and since your server will not return Access-Control-Allow-Origin: evil.com, the browser blocks it.
Strengths: Simple to implement for single-page applications. No server-side token state needed.
Limitations: Only works for AJAX requests (not form submissions). Requires your API to reject requests without the header. Does not protect HTML form endpoints.
The Origin header tells the server which site initiated the request. By verifying it matches your domain, you can reject cross-origin requests.
// Middleware: Validate Origin header
function validateOrigin(req, res, next) {
const allowedOrigins = [
'https://yourdomain.com',
'https://www.yourdomain.com',
];
const origin = req.headers['origin'];
const referer = req.headers['referer'];
// POST/PUT/DELETE requests must have a valid origin
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
if (origin) {
if (!allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Forbidden origin' });
}
} else if (referer) {
// Fallback to Referer header
const refererOrigin = new URL(referer).origin;
if (!allowedOrigins.includes(refererOrigin)) {
return res.status(403).json({ error: 'Forbidden origin' });
}
} else {
// No Origin or Referer — block the request
return res.status(403).json({ error: 'Missing origin' });
}
}
next();
}
Strengths: No tokens or state required. Works for all request types. This is what Next.js Server Actions use internally.
Limitations: Some privacy extensions strip the Referer header. The Origin header is not sent on some same-origin requests. You need a fallback strategy when both headers are absent.
The double-submit cookie pattern is the stateless alternative to the synchronizer token, for apps that have nowhere to keep server-side session state. The server sets a random token in a cookie and expects the same value back in a header or form field. An attacker's page can make the browser send the cookie, but cannot read its value to echo it back.
// Server: Set a CSRF cookie on page load
import crypto from 'crypto';
function setCsrfCookie(res) {
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', token, {
httpOnly: false, // JavaScript needs to read this
secure: true,
sameSite: 'Lax',
});
}
// Client: Read the cookie and send it as a header
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
}
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
body: JSON.stringify({ to: 'account', amount: 100 }),
});
// Server: Compare cookie value with header value
function verifyDoubleSubmit(req, res, next) {
const cookieToken = req.cookies['csrf-token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
}
The naive version above trusts any value that appears in both places, so anyone who can write a cookie on your domain can forge both halves. OWASP recommends the signed double-submit variant: bind the token to the session with an HMAC, so a value the attacker injected will not verify.
// Signed double-submit: bind the token to the session, then verify the binding
import crypto from 'crypto';
const SECRET = process.env.CSRF_SECRET; // 32+ random bytes, not in the repo
function issueToken(sessionId) {
const nonce = crypto.randomBytes(16).toString('hex');
const mac = crypto
.createHmac('sha256', SECRET)
.update(`${sessionId}!${nonce}`)
.digest('hex');
return `${nonce}.${mac}`; // goes in the cookie AND to the client
}
function verifyToken(token, sessionId) {
const [nonce, mac] = String(token ?? '').split('.');
if (!nonce || !mac) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${sessionId}!${nonce}`)
.digest('hex');
// Constant-time compare — a plain === leaks timing information
const a = Buffer.from(mac, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Two details that matter more than the algorithm: compare with crypto.timingSafeEqual rather than ===, and name the cookie with the __Host- prefix (__Host-csrf), which browsers only accept when it is Secure, has Path=/, and carries no Domain attribute — that last condition is what stops a subdomain from overwriting it.
Strengths: No server-side session state needed. Works well with REST APIs and SPAs.
Limitations: The unsigned version falls to anyone who can set cookies on your domain (subdomain XSS, cookie injection). The token cookie cannot be httpOnly if JavaScript has to read it back. For high-value operations, prefer the synchronizer token pattern.
SameSite is the highest-leverage line in your cookie config and the one most often left to the browser's default. MDN's Set-Cookie reference is the authoritative description of the behavior — here is what it means for your app.
First, the definition that causes most of the surprises: "site" means the registrable domain, not the origin. app.example.com, staging.example.com and example.com are all the same site, and so are http:// and https:// versions of the same host in some legacy clients. Scheme and port do not create a new site the way they create a new origin.
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Strict
The browser never attaches the cookie to a request that started on another site — not on a form POST, not in an iframe, not even on a plain link click from Google or an email client.
Use it for: banking, admin consoles, internal tools — anywhere a state-changing request should never arrive from outside.
The cost: a user following an external link lands logged out, sees the public version of the page, and has to navigate once more before the session attaches. That first-click gap is why most consumer products do not ship Strict on their main session cookie.
The workaround is two cookies: a Lax "read" cookie that keeps the UI logged in on inbound navigations, and a Strict "action" cookie that your server requires on every state-changing route.
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
The cookie rides along on top-level navigations — a link click, a GET form — and is dropped on every cross-site subrequest. That is the right default for most apps: the classic auto-submitting attack form gets no cookie, while inbound links still feel logged in.
| Cross-site request | Lax sends cookie? | Strict sends cookie? |
|---|---|---|
Link click (<a href>) | Yes | No |
Top-level GET form | Yes | No |
Top-level POST form | No | No |
<iframe> | No | No |
fetch / XHR | No | No |
<img>, <script> | No | No |
Read row two carefully. A top-level cross-site GET does carry the cookie. If any route of yours changes state on GET, Lax buys you nothing there.
Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None
The cookie goes out on every cross-site request. Secure is mandatory — browsers reject SameSite=None without it. You genuinely need None for third-party embeds rendered inside a customer's iframe, some cross-site SSO and OAuth round trips, and hosted payment frames.
SameSite=None puts you back where the web was before 2020: browser-level CSRF protection is off, and a token or Origin check is the only thing between an attacker's page and your endpoints. Never flip a cookie to None "to make the redirect work" without adding a token in the same change.
Chrome has treated cookies with no SameSite attribute as SameSite=Lax since Chrome 80 rolled out in 2020, and it rejects SameSite=None that is not also Secure. Other engines differ in the details and in which versions shipped them, and non-browser clients — mobile webviews, HTTP libraries, your own integration tests — implement whatever they feel like.
So set the attribute explicitly on every cookie you issue. "The default protects us" is a claim about one browser's current behavior, not about your application.
GET endpoints. Lax sends cookies on top-level GET navigations, so GET /account/delete?confirm=true is exploitable with nothing more than a link in an email.staging.yoursite.com hands the attacker a same-site position: their requests carry your cookies, and they can overwrite an unsigned double-submit cookie. __Host- prefixes and signed tokens are the fix; SameSite is not.None. Every embed, SSO hop, or payment iframe you support is a cookie with browser protection switched off.OWASP's guidance is unambiguous on this: treat SameSite as defense in depth and keep a token-based or origin-based check as the primary control.
Server Actions (App Router) have built-in CSRF protection. Per the Next.js docs they are POST-only and the framework compares the Origin header against the Host header, rejecting the request when they disagree. No configuration needed:
// app/actions.ts — Protected by default
'use server';
export async function transferFunds(formData: FormData) {
// This action is CSRF-protected automatically.
// Next.js rejects requests where Origin !== Host.
const to = formData.get('to');
const amount = formData.get('amount');
// ...
}
API Route Handlers need manual protection. Next.js does not apply CSRF checks to Route Handlers:
// middleware.ts — Add CSRF protection to API routes
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
const origin = req.headers.get('origin');
const host = req.headers.get('host');
if (!origin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const originHost = new URL(origin).host;
if (originHost !== host) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
Two Next.js gotchas worth knowing. If you sit behind a proxy or serve from multiple hostnames, the built-in Server Action check can reject legitimate traffic — Next.js exposes experimental.serverActions.allowedOrigins in next.config.js for exactly that case, and it is an allowlist, so keep it tight. And the built-in check protects Server Actions only: anything you expose under app/api/ is a plain route handler with no CSRF logic attached. More Next.js-specific hardening lives in our Next.js security best practices guide.
Do not reach for csurf — the package is deprecated and no longer maintained, and installing it today only gets you an unmaintained dependency with a deprecation notice. Use csrf-csrf, which implements the signed double-submit pattern described above:
import { doubleCsrf } from 'csrf-csrf';
import cookieParser from 'cookie-parser';
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__csrf',
cookieOptions: {
httpOnly: true,
secure: true,
sameSite: 'lax',
},
getTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
app.use(cookieParser());
app.use(doubleCsrfProtection);
// Endpoint to get a CSRF token for the frontend
app.get('/api/csrf-token', (req, res) => {
const token = generateToken(req, res);
res.json({ token });
});
One caveat: doubleCsrfProtection runs on every non-idempotent request, so mount it after cookieParser() and after body parsing, and exempt only the routes that genuinely cannot carry a token (inbound webhooks, which you should be verifying by signature instead).
Django has CSRF protection built into its middleware and enabled by default:
# settings.py — CSRF is on by default
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
# ...
]
# For APIs using custom headers instead of form tokens:
CSRF_TRUSTED_ORIGINS = [
'https://yourdomain.com',
'https://www.yourdomain.com',
]
<!-- Django templates include the token automatically -->
<form method="POST">
{% csrf_token %}
<input type="text" name="to" />
<button type="submit">Transfer</button>
</form>
For Django REST Framework API endpoints, you can use session authentication with CSRF or switch to token-based auth (which is inherently CSRF-proof since the token is in a header, not a cookie):
# For API views that need CSRF exemption (token-auth only)
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # Only safe if using non-cookie auth (Bearer tokens)
def api_view(request):
pass
Note that Django REST Framework's SessionAuthentication enforces CSRF for you on unsafe methods. Reaching for @csrf_exempt to silence a 403 on a session-authenticated endpoint removes the protection rather than fixing the client.
Rails ships forgery protection on by default for apps loading modern framework defaults, and form_with injects the authenticity_token hidden field automatically. The whole configuration is one line:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception # raise instead of silently resetting the session
end
<%# app/views/layouts/application.html.erb — exposes the token to JS clients %>
<%= csrf_meta_tags %>
csrf_meta_tags writes the token into a meta tag, and Rails' bundled front end — rails-ujs on older apps, Turbo on Rails 7+ — reads it back out and sets X-CSRF-Token on requests it makes for you. Hand-rolled fetch calls get nothing automatically: read the meta tag yourself and send the same header.
Laravel's VerifyCsrfToken middleware is in the web middleware group by default, so every session-backed POST route is already protected. Blade gives you the token in one directive:
{{-- resources/views/transfer.blade.php --}}
<form method="POST" action="/transfer">
@csrf
<input type="text" name="to">
<button type="submit">Transfer</button>
</form>
For JavaScript clients, Laravel also sets an XSRF-TOKEN cookie and accepts the value back in the X-XSRF-TOKEN header — Axios does this automatically, fetch does not. Exclude routes only when you must: in Laravel 11+ that is $middleware->validateCsrfTokens(except: ['stripe/*']) in bootstrap/app.php, and in older versions it is the $except array on the middleware class.
The answer has nothing to do with whether you built a SPA, a REST API, or a server-rendered monolith. It comes down to one question: does the browser attach your credentials automatically? If it does, the endpoint is forgeable. If the client has to write the credential into the request itself, it is not.
| How the client authenticates | Browser attaches it by itself? | CSRF protection needed? |
|---|---|---|
| Session cookie | Yes | Yes |
| JWT stored in a cookie | Yes | Yes |
Authorization: Bearer from memory or localStorage | No | No |
| HTTP Basic auth | Yes (browser re-sends) | Yes |
| Client TLS certificate | Yes | Yes |
| API key in a custom header | No | No |
This is where most "we're an API, CSRF doesn't apply" bugs come from. Route handlers sitting behind a session cookie, Laravel Sanctum in SPA mode, Django REST Framework with SessionAuthentication — in every one of those the cookie is doing the authentication, so an attacker's page can trigger the call. Content-Type: application/json is not a defense either: a form can be made to post text/plain that a lenient parser will happily read as JSON. Require a token or validate Origin.
If the only way in is an Authorization header your JavaScript sets explicitly, an attacker's page is stuck. It can fire a cross-origin request, but it cannot add that header without triggering a CORS preflight your server will refuse, and it cannot read your token out of another origin's context. Two conditions have to hold: your API must not also accept the same credential from a cookie or a query parameter as a fallback, and your CORS policy must not reflect arbitrary origins with Access-Control-Allow-Credentials: true. Our API security checklist covers both.
The usual reasoning is "put the token in localStorage, then CSRF is impossible." True, and it hands you a worse problem:
localStorage: immune to CSRF, fully exposed to XSS. Any injected script reads it and ships it off-site, where it keeps working until it expires.httpOnly cookie: exposed to CSRF unless you add a token, but unreadable by injected scripts. XSS can still use the session by making same-origin requests from the victim's browser — it just cannot walk off with the credential.XSS defeats both models, so it is not the tiebreaker people think it is. What differs is exfiltration: a stolen bearer token is replayable from the attacker's own machine, an httpOnly cookie is not. The practical setup is an httpOnly + Secure + SameSite cookie with a CSRF token on top, or an access token held in memory only (never localStorage) with a refresh token in an httpOnly cookie.
Covered at length above, and worth repeating because it is the single most common failure: Lax does not cover state-changing GET routes, does not treat your own subdomains as hostile, is switched off entirely on any cookie you set to None, and is not enforced by every client that talks to you. Pair it with at least one other method.
GET /api/account/delete?confirm=true
This is vulnerable even with SameSite Lax because Lax allows cookies on top-level GET navigations. An attacker just needs a link. Never use GET for state-changing operations.
If an attacker exploits an XSS vulnerability on any subdomain of your application, they can:
This is why CSRF tokens stored in server-side sessions are more secure than double-submit cookies for high-security applications.
Attackers can force-logout your users using CSRF on the logout endpoint. This might seem harmless, but it can be chained with other attacks — for example, logging the user out and then presenting a phishing login page. Always protect your logout endpoint with CSRF checks, or use POST for logout instead of GET.
Never put CSRF tokens in query parameters:
<!-- BAD: Token leaked via Referer header and browser history -->
<a href="/transfer?csrf=abc123&to=savings">Transfer</a>
CSRF tokens in URLs get logged in server access logs, browser history, and the Referer header sent to other sites. Always transmit tokens in request bodies or headers.
CheckVibe's automated security scanner checks your web application for CSRF vulnerabilities as part of its 36-point security audit. Specifically, the CSRF scanner:
<form> elements with method="POST" and checks whether they include a hidden CSRF token field.SameSite attribute set and flags cookies with SameSite=None that could be vulnerable.Origin header or require a CSRF token.Content-Type validation and CORS configuration.Run a free scan at checkvibe.dev to check your application for CSRF vulnerabilities and 35 other security issues.
Yes. Default SameSite behavior removed the easiest version of the attack, not the class. Cookies flipped to SameSite=None for embeds or SSO, state-changing GET routes, subdomain takeovers, and non-browser clients all put it back on the table. OWASP still maintains a dedicated CSRF Prevention Cheat Sheet, and CSRF sits under Broken Access Control in the OWASP Top 10.
It stops the classic version. A cross-site form that auto-submits a POST to your app gets no cookie under Lax. It does not stop CSRF on routes that change state via GET, which Lax still allows on top-level navigations. It does not stop requests from your own subdomains, which count as same-site. And it does nothing for any cookie you had to set to SameSite=None. Keep a token or Origin check as the primary control.
Only if the JWT travels in an Authorization header. The token format is irrelevant — what matters is whether the browser attaches it on its own. A JWT stored in a cookie is sent automatically with cross-site requests, so a cookie-based JWT session is exactly as forgeable as a classic session ID and needs the same protection. A JWT held in memory and set as a header by your own code is not.
The synchronizer token pattern stores the expected token server-side against the session and compares the submitted value to it. The double-submit cookie pattern stores nothing: the same value goes out in a cookie and comes back in a header or field, and the server checks the two match. Double-submit scales statelessly but falls to anyone who can write cookies on your domain, so use the HMAC-signed variant, or the synchronizer pattern for high-value actions.
It depends on how clients authenticate. If your API uses cookie-based sessions (common when the frontend and API share a domain), yes — you need CSRF protection. If it uses Authorization: Bearer <token> headers exclusively, and never accepts the same credential from a cookie or query string as a fallback, CSRF is not a concern because the browser does not attach the token by itself. Our API security checklist covers the rest.
Server Actions in the App Router are POST-only and compare the Origin header to the Host header, rejecting mismatches automatically — no configuration needed, though experimental.serverActions.allowedOrigins exists for proxy setups. Route Handlers under app/api/ get none of this. You implement Origin validation yourself in middleware or per route. See our Next.js security best practices for more.
CSRF makes a user's browser perform an unwanted action on a site where they are already authenticated, and the attacker never sees the response. XSS injects a script that runs inside your origin, letting the attacker read data, steal tokens, and do anything the user can. XSS also defeats CSRF defenses, because a same-origin script can simply read the CSRF token first. Fix XSS first — see our XSS vulnerability guide.
CSRF protection is a fundamental part of web application security. The best defense combines multiple layers: SameSite cookies as the baseline, Origin header validation as the primary check, and synchronizer or signed double-submit tokens for anything that moves money or changes access. No single method is bulletproof, but stacked they make cross-site request forgery impractical.
Scan your site with CheckVibe to check for CSRF vulnerabilities, missing security headers, and 34 other security issues — free for your first scan.
Paste your URL and get a security report in 30 seconds — 100+ automated checks with AI-ready fix prompts.
Scan your site freeRelated articles
The 7 most dangerous JWT security mistakes developers make. Algorithm confusion, weak secrets, missing expiration, and more — with code examples showing how to fix each one.
Next.js apps are fast to build but easy to misconfigure. Here are 10 specific security issues most developers miss, with code examples for each vulnerability and its fix.
The OWASP Top 10 explained without the enterprise jargon. Practical examples from Next.js, Supabase, and React apps that indie hackers actually build.