Security Guide
MCP server Origin header security — CSRF validation, Origin null from sandboxed iframes, Referer suppression, same-origin GET without Origin
The Origin header is the browser's clearest signal about where a cross-origin request is coming from. Unlike the Referer header, it cannot be suppressed by a privacy policy, does not leak URL paths or query parameters, and is classified as a forbidden header — JavaScript running in the page cannot forge or remove it. But two subtle behaviors trip up nearly every server-side CSRF validation that relies on Origin: the browser does not send Origin on same-origin GET requests (so absence is not a CSRF signal), and Origin: null is a legitimate value sent from sandboxed iframes, data: URIs, and local files — allowlisting null opens CSRF from any sandboxed context on the web.
Origin header as CSRF validation point
The Origin header is set by the browser on cross-origin requests and on same-origin requests for most non-GET HTTP methods (POST, PUT, DELETE, PATCH). Because it is a Forbidden Request Header, no JavaScript running in the browser context can set, modify, or remove it — the browser owns it exclusively. This makes it a strong signal for server-side CSRF defense: if the Origin header is present and names a specific origin, that value can be trusted to accurately represent where the request came from.
Checking the Origin header against a known-good allowlist on the server is a reliable CSRF defense for endpoints that receive cross-origin fetch() calls. The validation is straightforward: if the header is present, it must match an entry in the allowlist exactly; if it does not match, reject the request. If the header is absent (discussed below), fall through to the secondary CSRF token check rather than treating absence as either "safe" or "blocked."
The Origin header approach works well for MCP server endpoints accessed via the Fetch API from browser-based MCP clients. It is not effective against HTML form submissions (which do include Origin for POST but not in all browsers under all conditions) or against requests made from server-side code (where no browser is involved and Origin may be absent or arbitrary). Defense-in-depth means layering Origin validation with CSRF tokens and SameSite cookie attributes rather than relying on Origin alone.
Origin vs Referer: use Origin as the primary CSRF check and treat Referer as optional logging only. Origin is shorter (scheme + host + port, no path), is not suppressible by Referrer-Policy, and is not a forbidden header in the same implementations where Referer can be omitted. If your server currently relies on Referer for CSRF validation, this guide explains exactly why that is fragile and how to fix it.
Why Origin is more reliable than Referer for CSRF validation
The Referer header has been used for CSRF validation for over a decade — check that the request came from the same domain by inspecting the URL in the Referer header, and reject requests whose Referer doesn't match. This approach has two structural weaknesses that make it unsuitable as a primary CSRF control in modern deployments.
First, Referer can be entirely suppressed. The Referrer-Policy HTTP response header controls whether and how much of the URL the browser includes in the Referer header on subsequent requests. A setting of Referrer-Policy: no-referrer instructs the browser to send no Referer header at all — not even on requests to the same origin. This is a common privacy recommendation (it prevents the server's URLs from leaking into third-party access logs), and it is widely deployed. A server that validates CSRF by checking the Referer header will reject all requests from browsers respecting a no-referrer policy — breaking legitimate users while simultaneously being bypassable by not sending the header at all.
Second, Referer includes the full URL — scheme, host, port, path, and query string. This creates privacy leakage: if the URL contains a password-reset token or a session identifier as a query parameter, that token appears in the Referer header of every subsequent request the user makes from that page. This is a separate problem from CSRF, but it is why Referrer-Policy policies that strip the URL to just the origin (origin or strict-origin) are common — which means the Referer header on those deployments contains only scheme and host anyway, making it indistinguishable from the Origin header in content while being less reliable in availability.
| Property | Origin header | Referer header |
|---|---|---|
| Content | scheme + host + port only | Full URL (scheme, host, port, path, query) |
| Suppressible by Referrer-Policy? | No | Yes — no-referrer removes it entirely |
| Forbidden header (JS cannot set)? | Yes | Yes |
| Sent on same-origin GET? | No (absent) | Yes (in most browsers) |
| Null value possible? | Yes — sandboxed iframes, data: URIs | Yes — navigation from local files |
| Path leakage risk? | None — no path included | Yes — full URL path exposed |
Origin: null — the dangerous special case
Browsers send Origin: null — the literal string "null" as the header value, not an absent header — in several specific contexts: requests from data: URIs (such as an image or anchor in a data:text/html,... document), requests from sandboxed iframes (those with a sandbox attribute that does not include allow-same-origin), and in some browsers, requests originating from local files opened via file://. In each case, the browser cannot assign a meaningful origin to the request because the source document does not have a scheme-host-port tuple, or because sandboxing has stripped its origin.
The security risk is that null is often added to CORS or CSRF Origin allowlists to support local development — developers open file:///Users/dev/index.html, make a fetch to the development API, and add null to the allowlist when the request fails. If that allowlist entry is ever deployed to production or staging, any attacker who can serve a sandboxed iframe or a data: URI link to a victim can make authenticated cross-origin requests to the MCP server from the null origin — and the allowlist check will approve them.
Sandboxed iframes are particularly effective as an attack vehicle because they can be embedded on any webpage, including attacker-controlled sites. A sandboxed iframe with JavaScript execution permissions but without allow-same-origin produces an Origin: null request. Any origin on the internet can create such an iframe. If the MCP server's allowlist includes null, any website can perform authenticated CSRF attacks against the MCP server on behalf of visiting users.
// VULNERABLE: allowlist includes null — sandboxed iframe CSRF bypass
const ALLOWED_ORIGINS = new Set([
'https://app.example',
'https://staging.example',
'null', // Added for local file development — CRITICAL mistake in production
]);
app.post('/mcp/tool/invoke', (req, res) => {
const origin = req.headers['origin'];
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'CSRF check failed: origin not allowed' });
}
// If origin is 'null' (from sandboxed iframe or data: URI), the check passes.
// Attacker's webpage:
// <iframe sandbox="allow-scripts" src="attacker-payload.html"></iframe>
// Inside attacker-payload.html:
// fetch('https://mcp-api.example/mcp/tool/invoke', {
// method: 'POST', credentials: 'include',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ tool: 'delete_workspace' })
// });
// Browser sends Origin: null. Allowlist check passes. Tool executes.
executeTool(req.body);
res.json({ status: 'ok' });
});
// SAFE: strict allowlist with null explicitly excluded + CSRF token as depth
const ALLOWED_ORIGINS = new Set([
'https://app.example',
'https://staging.example',
// 'null' is intentionally absent — never allowlist null in production
]);
function originCsrfMiddleware(req, res, next) {
const origin = req.headers['origin'];
if (origin !== undefined) {
// Origin header is present — validate it strictly
if (origin === 'null') {
// Reject null origin explicitly with a clear error
return res.status(403).json({
error: 'CSRF check failed: null origin (sandboxed context) not permitted'
});
}
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({
error: 'CSRF check failed: origin not in allowlist'
});
}
// Origin is present and allowlisted — pass through to route handler
return next();
}
// Origin header is absent — normal for same-origin GET requests.
// Do NOT treat absence as safe for state-changing requests.
// Fall through to CSRF token validation as the secondary check.
const csrfToken = req.headers['x-csrf-token'] || req.body?._csrf;
const sessionCsrfToken = req.session?.csrfToken;
if (!csrfToken || csrfToken !== sessionCsrfToken) {
return res.status(403).json({
error: 'CSRF check failed: missing or invalid CSRF token'
});
}
next();
}
Same-origin GET requests — Origin header is absent
Browsers do NOT send the Origin header on same-origin GET requests in typical (non-CORS) mode. When a user's browser makes a same-origin GET fetch (for example, the MCP client's JavaScript calling the same-origin MCP server), the Origin header is simply absent from the request. This is specified behavior, not a browser bug.
The critical implication for server-side validation: absence of the Origin header does NOT mean the request is cross-origin and suspicious. It is the normal state for same-origin GET requests. A server that rejects all requests missing the Origin header will break every same-origin GET call its legitimate clients make. Conversely, a server that treats absence of Origin as "definitely safe, skip CSRF check" is bypassed by any attacker who strips or suppresses the header — but since Origin is a forbidden header, JavaScript cannot strip it; the absence on GETs is browser-controlled and not attacker-injectable from within the browser.
The correct handling: validate Origin when it IS present and non-null; fall through to CSRF token validation when Origin is absent; do not rely on absence alone as either a safety signal or a rejection trigger. For state-changing operations (POST, PUT, DELETE, PATCH), the absence of Origin on same-origin requests is unusual enough that falling through to a CSRF token check is the right response — same-origin POST requests typically do include Origin in modern browsers.
Absence of Origin on a state-changing request (POST/PUT/DELETE) is suspicious in modern browsers. Modern Chrome and Firefox include Origin on same-origin POST requests. An absent Origin on a POST most often indicates an older browser, a server-side HTTP client (no browser involved), or a proxy that stripped it. Treat absence as "fall through to CSRF token check," not as "approved."
Referer suppression breaking Referer-based CSRF validation
If your MCP server currently validates CSRF by checking the Referer header, the deployment of Referrer-Policy: no-referrer on any page that calls your API will silently break all those calls. The server receives requests with no Referer header, its CSRF check fails (Referer absent means "can't verify origin, reject"), and legitimate users get 403 errors they cannot diagnose without server logs. Meanwhile, an attacker who knows Referer-only validation is in use can suppress the Referer header by navigating from a Referrer-Policy: no-referrer page, bypassing the check.
The no-referrer policy is increasingly common because it is a genuine privacy improvement — it prevents URLs from leaking to server logs when users click links. Browser security tooling, CDNs, and Content Security Policy generators often recommend adding Referrer-Policy: no-referrer to the response headers of any page that might contain sensitive URL parameters. If your application pages have this header and your MCP server validates CSRF via Referer, you have a reliability failure already deployed.
The remediation is to migrate from Referer-based CSRF validation to Origin-based validation (primary check) plus CSRF token validation (secondary check). Referer can be retained as supplemental logging data — recording where the request came from for audit purposes — but should not be a gatekeeper that rejects legitimate requests when absent.
Origin allowlists and explicit port handling
The Origin header always includes the port when the port is non-default: https://app.example:8443 is a different origin from https://app.example, even though both use HTTPS. The default port for HTTPS is 443; if your application runs on port 8443, the Origin header will include the port number. An allowlist entry of https://app.example does NOT match a request from https://app.example:8443 — the browser sends the full origin including port, and a strict equality check will reject it.
This matters in practice for MCP server deployments that run the API on a non-standard port during staging or behind a load balancer with a different external port than the internal port. If the allowlist was written using the internal port but clients connect via the external port (or vice versa), all cross-origin requests fail. Enumerating origins explicitly in the allowlist — including the port where non-default — is the safest approach: it makes the allowed surface area explicit and reviewable.
// Port-explicit allowlist — correct handling of non-default ports
const ALLOWED_ORIGINS = new Set([
'https://app.example', // port 443 (implicit — no port in header)
'https://app.example:8443', // explicit non-default port — different origin
'https://staging.example',
'https://staging.example:3000', // staging dev server on port 3000
// Do NOT add:
// 'http://app.example' — HTTP and HTTPS are different schemes, different origins
// 'null' — never allowlist null in production
]);
// Typical Origin header values and whether they match:
// 'https://app.example' → allowlisted ✓
// 'https://app.example:8443' → allowlisted ✓
// 'https://app.example:443' → NOT in set (browsers omit default port) ✗
// 'https://app.evilexample.com' → NOT in set ✗
// 'null' → NOT in set — rejected explicitly ✗
function validateOrigin(origin) {
if (!origin || origin === 'null') return false;
// Exact match only — no prefix matching, no regex
return ALLOWED_ORIGINS.has(origin);
}
Defense-in-depth: Origin check plus CSRF token
Origin header validation is a strong primary CSRF defense for requests that include the header — which covers the vast majority of modern browser cross-origin fetch calls. But single-factor protection means a single implementation mistake or browser quirk can eliminate the protection entirely. CSRF tokens as a secondary layer ensure that even if Origin validation is bypassed (via a future browser behavior change, a proxy that removes Origin, or a new null-origin context), the CSRF token provides an independent barrier.
The layering works as follows: on authenticated session creation, generate a cryptographically random CSRF token and store it in the session (server-side) and deliver it to the client (via a cookie flagged HttpOnly: false so JavaScript can read it, or embedded in the initial HTML). On each state-changing request, the client sends the CSRF token in a custom header (e.g., X-CSRF-Token) or request body. The server validates both: Origin allowlist check first, then CSRF token check. An attacker who bypasses Origin validation still must know the CSRF token; an attacker who steals the CSRF token via XSS still must have a correct Origin.
Double-submit cookie pattern: a simpler CSRF token variant that avoids server-side session storage is the double-submit cookie: the server sets a SameSite=Strict cookie with a random token value; the client reads the cookie and echoes the value in a request header (X-CSRF-Token); the server verifies both match. Cross-origin attackers cannot read the cookie (same-origin policy) and therefore cannot echo the correct value — without needing the server to store token state.
SkillAudit findings for Origin header misconfigurations
null as a permitted origin. Any attacker who can deliver a sandboxed iframe (<iframe sandbox="allow-scripts">) or a data: URI to a victim can make authenticated cross-origin requests to the MCP server. The null origin check passes and the request executes with the victim's credentials. Grade impact: −24.
Referer header and rejecting requests where it is absent or does not match. A Referrer-Policy: no-referrer header on any calling page suppresses Referer entirely, breaking legitimate requests and simultaneously bypassing the check for attackers who know to navigate from a no-referrer context. Grade impact: −22.
origin.startsWith('https://app.example') or a regex with an unescaped dot. An origin of https://app.evilexample.com matches the prefix https://app.e and passes the check. Exact set membership lookup (ALLOWED_ORIGINS.has(origin)) is the only correct implementation. Grade impact: −18.
https://app.example but the application is served from https://app.example:8443. The browser sends the full origin including port; the allowlist entry without the port does not match. Either all cross-origin requests fail (reliability issue) or someone added a workaround that is overly permissive. Grade impact: −14.
Audit your MCP server for these issues
SkillAudit checks for Origin header misconfigurations — including null-origin allowlisting, Referer-only CSRF validation, prefix-match allowlists, and missing CSRF token depth — automatically. Paste a GitHub URL and get a graded report in 60 seconds.
Run a free audit →