remix-run/react-router · error · Error

The `request.url` origin does not match `origin` header from

Error message

The `request.url` origin does not match `origin` header from a forwarded action request. Aborting the action.

What it means

React Router adds a CSRF-protection layer for mutation requests (POST/PUT/PATCH/DELETE) to UI routes: before running an action, throwIfPotentialCSRFAttack (packages/react-router/lib/actions.ts:1) parses the browser's `origin` header and compares its host against the host in `request.url`. If they differ and the origin host is not matched by the `allowedActionOrigins` config (exact host or `*`/`**` wildcard), the action is aborted with this error. In the built-in document and single-fetch handlers the throw is converted into a 400 Bad Request; in RSC/custom-server paths it surfaces via onError or rejection.

Source

Thrown at packages/react-router/lib/actions.ts:29

      originUrl = new URL(originHeader);
      originDomain = originUrl.host;
    } else {
      originDomain = originHeader;
    }
  } catch {
    throw new Error(
      `\`origin\` header is not a valid URL. Aborting the action.`,
    );
  }
  let requestUrl = new URL(request.url);
  let originMatchesRequest = originUrl
    ? originUrl.origin === requestUrl.origin
    : originDomain === requestUrl.host;

  if (originDomain && !originMatchesRequest) {
    if (!isAllowedOrigin(originDomain, allowedActionOrigins)) {
      // This seems to be an CSRF attack. We should not proceed with the action.
      throw new Error(
        "The `request.url` origin does not match `origin` header from a forwarded " +
          "action request. Aborting the action.",
      );
    }
  }
}

// Implementation of micromatch by Next.js https://github.com/vercel/next.js/blob/ea927b583d24f42e538001bf13370e38c91d17bf/packages/next/src/server/app-render/csrf-protection.ts#L6
function matchWildcardDomain(domain: string, pattern: string) {
  const domainParts = domain.split(".");
  const patternParts = pattern.split(".");

  if (patternParts.length < 1) {
    // pattern is empty and therefore invalid to match against
    return false;
  }

  if (domainParts.length < patternParts.length) {

View on GitHub (pinned to c091832969)

Solutions

  1. If the cross-origin submission is legitimate, add the posting host to `allowedActionOrigins` in react-router.config.ts: allowedActionOrigins: ["www.example.com", "*.example.com"] — `*` matches one subdomain segment, `**` matches multiple.
  2. If the app should be same-origin, fix the proxy/adapter so request.url carries the public host: enable `trust proxy` in @react-router/express (app.set("trust proxy", true)) and make the proxy forwards Host / X-Forwarded-Host and X-Forwarded-Proto.
  3. Need the value per environment? Set it at runtime on the ServerBuild in your custom server instead of the static config: return { ...build, allowedActionOrigins: ["staging.example.com"] } from the build getter passed to createRequestHandler.
  4. Eliminate `origin: null` senders: remove `sandbox` attributes that opaque-ify the iframe origin, or move the form/action to be served from the same origin.
  5. After upgrading React Router, smoke-test every mutation request (form POST, fetcher.submit, server action) in the deployed environment, since the CSRF check behavior changed across versions.

Example fix

// before: every POST behind the proxy returns 400 "Bad Request"
// react-router.config.ts
export default {
  // no allowedActionOrigins -> only exact request.url host is accepted
} satisfies Config;

// after: option A - allowlist the public hosts (react-router.config.ts)
export default {
  allowedActionOrigins: ["www.example.com", "*.example.com"],
} satisfies Config;

// after: option B - make request.url match reality (custom express server)
export const app = express();
app.set("trust proxy", 1); // honor X-Forwarded-Host/Proto from your proxy
Defensive patterns

Strategy: validation

Validate before calling

// Before shipping, assert the server reconstructs request.url with the public host
// and that every front-door host is allowlisted (custom express server example):
import { createRequestHandler } from "@react-router/express";

const PUBLIC_HOSTS = ["www.example.com", "staging.example.com"];

app.use((req, res, next) => {
  const host = req.hostname; // relies on trust proxy when behind a proxy
  if (!PUBLIC_HOSTS.includes(host) && !host.endsWith(".example.com")) {
    return res.status(400).send("Unexpected host");
  }
  next();
});

app.use(
  createRequestHandler({
    build: async () => ({
      ...(await import("virtual:react-router/server-build")),
      allowedActionOrigins: PUBLIC_HOSTS,
    }),
  }),
);

Try / catch

// Only relevant if you call the handler directly (e.g. matchRSCServerRequest
// or createRequestHandler in a custom server); the built-in document and
// single-fetch paths already convert this throw into a 400 response.
try {
  response = await handler(request, loadContext);
} catch (error) {
  if (
    error instanceof Error &&
    error.message.includes("does not match `origin` header")
  ) {
    return new Response("Bad Request", { status: 400 });
  }
  throw error;
}

Prevention

When it happens

Trigger: A POST/PUT/PATCH/DELETE submitted to a UI route where request.headers.get("origin") has a host that differs from new URL(request.url).host, e.g. the origin header says https://www.example.com but request.url is http://localhost:3000/... — and "www.example.com" is not in allowedActionOrigins. Concretely: (1) form/fetcher submissions or server actions issued from a page served on a different domain than the one the server thinks it is on; (2) a reverse proxy (nginx, ALB, Cloudflare, ngrok) that does not preserve the Host header, so the adapter builds request.url with an internal host; (3) requests carrying `origin: null` (sandboxed iframes, privacy-mode browsers, cross-origin redirects), since the literal string "null" never equals the request host; (4) staging/preview deployments on subdomains that were never allowlisted.

Common situations: Deploying @react-router/serve or @react-router/express apps behind a reverse proxy without `trust proxy` enabled — a documented "breaking bug fix" in the React Router changelog notes the check now compares against the host in the request URL instead of headers. Also common after upgrading React Router versions that introduced/changed this CSRF check (PR #14708, #14722), testing a local dev server through a tunnel (ngrok/localtunnel), embedding or posting from another owned domain (micro-frontends, marketing-site forms), or forgetting that preview URLs (Vercel/Netlify deploys) are different origins.

Related errors


AI-assisted analysis of remix-run/react-router@c091832969 (2026-08-21). Data as JSON: /api/errors/b835cb3dbc464286. Report an issue: GitHub.