Next.js "Invalid Server Actions request": safe fix
Invalid Server Actions request. means Next.js's built-in CSRF check rejected the call: the host in the browser's Origin header didn't match the host your server thinks it is, read from X-Forwarded-Host or Host. It almost always happens behind a reverse proxy, a tunnel or a Codespaces port that forwards the wrong host. Fix the proxy so it forwards the public host (proxy_set_header X-Forwarded-Host $host; in nginx). If the page and the server really are on different hosts, list those exact hosts in serverActions.allowedOrigins. Never a wildcard over a shared domain, and never a rewrite of X-Forwarded-Host to match Origin.
The server log carries the real reason, one line above the error. In next@16.4.3 and today's canary, packages/next/src/server/app-render/action-handler.ts prints this before aborting:
`x-forwarded-host` header with value `localhost:3000` does not match `origin` header with value `app.example.com` from a forwarded Server Actions request. Aborting the action.
The first name is whichever header Next.js compared, so you may see `host` header with value … instead. Then it throws new Error('Invalid Server Actions request.'). For a call made from the client router, the response status is 500.
Why does Next.js say Invalid Server Actions request?
Because the request's Origin and the server's idea of its own host disagree, and Next.js treats that as a possible cross-site request forgery. The data security guide puts it this way: "Server Actions can only be invoked on the same host as the page that hosts it."
The check is short. In action-handler.ts:
- Take
Origin, parse it, keep itshost(hostname plus port, if any). - Take the first value of
X-Forwarded-Host. If there is none, takeHost. - If they differ, try
serverActions.allowedOrigins. If no entry matches, log the line above and throw.
It's a backstop. Server Actions only run on POST, and the same guide says that "prevents most CSRF vulnerabilities in modern browsers, particularly with SameSite cookies being the default." @supabase/ssr sets its auth cookies with sameSite: "lax" by default, so a cross-site form POST already arrives without your session. The origin check covers the cases where SameSite doesn't help: cookies set to SameSite=None, and an attacker on a sibling subdomain of your own site, which counts as same-site.
Where the mismatch comes from
Next.js sees whatever your infrastructure hands it. The common shapes:
- nginx in front of Docker. By default nginx rewrites
Hostto$proxy_host, the upstream inproxy_pass(app:3000, say), and adds noX-Forwarded-Host. The nginx docs list the defaults:proxy_set_header Host $proxy_host;andproxy_set_header Connection close;. The browser saysexample.com, the server saysapp:3000, and the action dies. - Codespaces, Gitpod and tunnels. The browser is on a forwarded URL. The dev server sees
localhost:3000. Issue #58295 collected reports from Codespaces, Heroku and nginx on ECS. - A port the proxy drops.
Originkeeps a non-default port (example.com:8443). nginx's$hostdoesn't, so the two strings differ by:8443. - A real second host. The page is served from one domain and the action posts to a server on another, the case the docs call "reverse proxies or multi-layered backend architectures."
The first three are fixed at the proxy. Only the last one needs config.
The safe fix: forward the right host
For nginx, pass the public host through explicitly. This is the block from nginx's docs, and the one that worked for commenters in #58295:
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
If the public URL has a non-standard port, use $http_host, which nginx passes "unchanged", port included. Setting the header with proxy_set_header also overwrites any X-Forwarded-Host the client sent, which is what you want.
Two more details from the source. Next.js reads only the first value of a comma-separated X-Forwarded-Host, so in a chain of proxies the outermost one has to set it. And the comparison is exact host to host. The serverActions reference is direct: "Behind a reverse proxy, no entry is needed as long as the proxy forwards the public host in x-forwarded-host."
When allowedOrigins is the right answer
Use it when the browser's host genuinely differs from the server's, and list the hosts you own:
// next.config.js
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['app.example.com', 'example.com'],
},
},
}
Entries are hosts, not URLs. Next.js compares them to new URL(origin).host, so https://example.com never matches anything, and localhost doesn't match localhost:3000. We ran isCsrfOriginAllowed from csrf-protection.ts (identical in 16.4.3 and canary) against a few inputs:
| Entry | Origin host | Allowed |
|---|---|---|
my-app.vercel.app | my-app.vercel.app | Yes |
localhost | localhost:3000 | No |
https://example.com | example.com | No |
*.example.com | app.example.com:8443 | No (ports can't be wildcarded) |
* or ** | evil.com | No (a lone wildcard is refused) |
*.vercel.app | attacker-app.vercel.app | Yes |
* matches exactly one label, and ** one or more, only at the start. The matching rules have changed between releases, so don't build on them. Write the hosts out.
For a tunnel in development, the docs show the hostname going in both allowedDevOrigins and allowedOrigins. Use your one reserved tunnel host, not the provider's whole domain, and keep it out of the production config. The reference notes that "the check runs in production as well as in development".
The fixes that turn the check off
When the error blocks a deploy, two shortcuts make it disappear.
A wildcard over a shared domain. *.vercel.app, *.ngrok-free.app or **.app.github.dev lists every app anyone else deploys there. Anyone can deploy to *.vercel.app, so the entry trusts a stranger's app as much as your own. For vercel.app and ngrok-free.app there's still a SameSite layer, because both are on the Public Suffix List. Lax cookies don't ride along on a POST from a neighbour there. app.github.dev isn't on the list, so neighbouring codespaces count as same-site. Either way, you've swapped the origin check for hope that your cookie settings hold.
Rewriting X-Forwarded-Host to match Origin. This shows up in proxy.ts (formerly middleware.ts):
// Don't do this: the CSRF check now compares Origin with itself.
export function proxy(request: NextRequest) {
const headers = new Headers(request.headers)
const origin = request.headers.get('origin')
if (origin) headers.set('x-forwarded-host', new URL(origin).host)
return NextResponse.next({ request: { headers } })
}
Next.js's server applies request-header overrides from proxy.ts before the action handler runs (resolve-routes.ts replaces req.headers). The comparison becomes Origin against itself, and every site on the internet passes. Copying the origin's host into X-Forwarded-Host at nginx does the same thing one layer earlier. If you need a stable host for your own code, read it from a SITE_URL env var, as in our open redirect post.
GuardLayer's scanner doesn't flag either pattern today. We ran both snippets above through it and got zero findings. Treat allowedOrigins and any header rewriting in proxy.ts as a manual review item, and add them to your Next.js + Supabase security checklist.
The other two messages
Missing `origin` header from a forwarded Server Actions request. is a warning, not a rejection. The source comment explains why: a request with no Origin is either handcrafted or from an unsafe browser, and handcrafted requests "can't contain user credentials that haven't been shared willingly." The action runs. You'll see this for curl and scripts, and the word "forwarded" appears even when no proxy exists.
Origin: null used to take that same path. GHSA-mq59-m269-xvcx (CVE-2026-27978, moderate) found that sandboxed iframes, which send Origin: null, could skip the check and run actions with the victim's cookies. It affects 16.0.1 through 16.1.6. Fixed versions treat 'null' as a real origin, rejected unless you allowlist it. Don't add 'null' to allowedOrigins. If you're below 16.1.7, you're also below the current safe Next.js version, so upgrade.
One more source of the same text: a no-JS form POST whose $ACTION_ID_ fields don't match the build gets Invalid Server Actions request. with status 400 and no mismatch line in the log. That's stale or crafted IDs, not the origin check. Scanner probes with fake IDs leave different log lines.
CSRF protection isn't authentication
Passing the origin check proves the request came from your host. It says nothing about who sent it. Anyone can call your actions from your own site, or with curl, which skips the check entirely. The Server Actions guide says to "treat every action as an untrusted entry point." Every action needs its own session and ownership check. That one GuardLayer does scan for: the Server Action auth check shows the rule and the fix.
FAQ
What causes "Invalid Server Actions request" in Next.js?
The host in the Origin header didn't match X-Forwarded-Host (or Host), and no allowedOrigins entry matched. The log line just before it names both values.
Is it safe to set allowedOrigins to a wildcard?
Only for a domain where you control every subdomain. *.vercel.app or *.ngrok-free.app trusts everyone else's deployments. List exact hosts.
Should allowedOrigins entries include https://?
No. Entries are compared with the origin's host, so write example.com or example.com:8443. An entry with a scheme never matches.
Why does it work locally but fail behind nginx?
nginx sends Host: $proxy_host (your upstream name) by default and no X-Forwarded-Host. Add proxy_set_header Host $host; and proxy_set_header X-Forwarded-Host $host;.
Does "Missing origin header from a forwarded Server Actions request" block the action?
No. It's a warning and the action runs. It usually means a script or curl sent the request.
Does the CSRF check protect my Server Actions from unauthorized users? No. It stops other sites from triggering actions in a victim's browser. Anyone can still call the action directly, so check the session and ownership inside each one.
Scan your Next.js + Supabase app — free
GuardLayer runs 36 checks — exposed keys, missing RLS, over-permissive policies, unprotected Server Actions — with the exact fix inline. Connect GitHub and it checks every push too — free on public repos.
No signup, no card. Raw files aren’t kept; only the report is saved.