A common rule in WebSocket servers reads: “reject the upgrade if the Origin header is not ours.” It is a good rule, and it is also the source of two opposite mistakes. Some teams read it as “only our app can connect”. Others see that a command-line tool or a native app sends no Origin at all, and conclude the server should refuse those.

Both come from the same misunderstanding of what the header is for. I wrote a small server and a browser attack and tested the header one claim at a time.

TL;DR

  • Origin is a statement by the browser about which page opened the connection. Page scripts cannot change it, but any other client can send whatever it likes. RFC 6455 says so directly: non-browser clients “can send fake Origin header fields”.
  • The allow-list exists for one attack: a hostile page makes the victim’s browser open a WebSocket to your server, and the browser attaches the victim’s cookies. In my test that attack worked with no check and failed with an exact-match check.
  • The same check gives no information about a non-browser client. A Node client with an allowed Origin and no cookie got a 401. With a stolen cookie it got in. The cookie authenticated it, the header did nothing.
  • So the rule is two-part. If Origin is present, it must match an allow-list exactly. Whatever its value, the connection still needs a credential that a browser does not attach on its own.
  • Matching with includes or startsWith lets https://app.example.com.evil.test through. Use a set lookup on the serialized origin.

What RFC 6455 says Origin is for

When a browser opens a WebSocket, it adds an Origin header naming the page’s origin (scheme, host, port). Page scripts cannot override it: MDN lists Origin among the forbidden request headers. RFC 6455 section 4.1 makes the header a MUST for browser clients, and a MAY for other clients “if the semantics of that client match the use-case described here”.

The purpose is written down in section 10.2: servers that want to limit use of their WebSocket service from scripts running in a browser should check Origin and answer 403 if it is not acceptable. The text is explicit that the aim is “not to prevent non-browsers from establishing connections but to ensure that trusted browsers under the control of potentially malicious JavaScript cannot fake a WebSocket handshake.” A server that does not check “will accept connections from anywhere” (section 4.2.2).

That is the whole contract. The header gives the server a way to see which website a browser is acting for. It says nothing else.

Misconception 1: “the allow-list means only our app connects”

I started a server that checks only an exact-match allow-list and a session cookie, then connected with a Node client that sends the allowed origin:

Node client, forged allowed Origin, no cookie    => HTTP 401
Node client, forged allowed Origin, with cookie  => OPEN {"hello":"alice", ...}

Without the cookie, the forged Origin bought nothing; with the cookie, it was unnecessary. The server authenticated the cookie. The header was neither required nor sufficient. A browser is the only client for which the header is trustworthy, because the browser is the one that enforces it.

Misconception 2: “we use cookies and SameSite, so we are fine”

This is the attack the allow-list is for, so I ran it for real. The setup:

  1. The server (port 4100) accepts a cookie session and does not look at Origin.
  2. In headless Chrome (version 154, driven by puppeteer-core), the victim visits http://localhost:4100/login and receives sid=...; SameSite=Lax.
  3. The same browser then opens a page served by a different server on port 4200. The page runs new WebSocket('ws://localhost:4100/ws').
MODE=none       page on :4200, victim cookie   => OPEN {"hello":"alice","origin":"http://localhost:4200"}
MODE=allowlist  page on :4200, victim cookie   => CLOSED code=1006   (server saw Origin=http://localhost:4200 -> 403)

The hostile page received the victim’s data. The server’s log shows it saw Origin: http://localhost:4200 and could have refused. This is what OWASP’s WebSocket cheat sheet calls cross-site WebSocket hijacking.

Why did SameSite=Lax not help? Because port 4200 and port 4100 on localhost are cross-origin but same-site: the site ignores the port. web.dev explains the difference. The same thing happens between sibling subdomains of one registrable domain. I only tested ports on localhost; the subdomain case follows from the definition and I did not run it. If you count on SameSite alone, any other page on a same-site host that can run script (a user-content subdomain, a staging copy) can open the connection with the victim’s cookie.

Misconception 3: “I match the origin, so it is checked”

Origins are compared as strings, so the way you compare matters. I tested three plausible implementations against four hostile values:

const ALLOWED = ['https://app.example.com'];
includes:   (o) => ALLOWED.some((a) => o.includes(new URL(a).host)),
startsWith: (o) => ALLOWED.some((a) => o.startsWith(a)),
exactSet:   (o) => new Set(ALLOWED).has(o),

includes accepted https://app.example.com.evil.test, https://evil.test/?https://app.example.com and https://notapp.example.com. startsWith accepted the first. The exact set lookup rejected all four, including the literal string null. The browser sends the serialized origin with no path and no trailing slash, so an exact comparison is both correct and easy.

Misconception 4: “no Origin means reject” (or “no Origin means fine”)

A request without Origin came from something that is not following the browser rules: a desktop app, a script, a server, a test tool. Reject all of those and your own non-browser client stops working. Accept them without a credential and the check has no meaning. The right reading is that a missing Origin removes the browser’s help, so the request has to carry its own proof.

The proof must be something a browser does not send by itself. A cookie fails that test: the whole attack above rides on it. An Authorization header cannot be set by the browser’s WebSocket API at all. What a page script can do is pass a subprotocol list, which is how my server accepts a bearer token (the OWASP sheet lists query strings and first-message tokens as other options; query strings show up in access logs).

Here is the decision as code. This is the function the lab server runs.

export function gate({ origin, bearer, allowed, tokens }) {
  if (origin !== undefined && !allowed.has(origin)) return { ok: false, status: 403, why: 'origin-not-allowed' };
  const user = tokens[bearer];
  if (!user) return { ok: false, status: 401, why: 'no-valid-bearer' };
  return { ok: true, user, kind: origin === undefined ? 'no-origin' : 'browser' };
}

origin is undefined when the header is absent. A sandboxed iframe sends the string null, which is not in the set, so it gets the 403.

The results, using the same server in gate mode:

ClientResult
Node, token, no Originopen
Node, token, forged allowed Originopen
Node, token, other Origin403
Node, no token401
Node, wrong token, allowed Origin401
Page on port 4200 (not allowed)closed, server saw the origin, 403
Sandboxed iframeclosed, server saw Origin: null, 403

Two lines in that table deserve a second look. “Token, forged allowed Origin: open” is fine. A client that holds a valid token is allowed to connect, and forging the header gains it nothing. “Token, other Origin: 403” is the browser-only part of the rule. A page you do not trust, running in a browser that somehow had the token, is still refused.

What to ask of the header, and what not to

Put an exact Origin allow-list on every WebSocket endpoint that a browser can reach, above all where the session is a cookie. It is cheap and it closes the cross-site case.

Do not use it as the only authentication, and do not use it to tell your native client from a script. The server cannot distinguish a client that tells the truth about itself from one that does not. If you need to know that the client is a particular app build, that is a different problem (app attestation, which I did not look at here), and the answer is a credential, not a header.

If an endpoint is reached only by non-browser clients with explicit tokens, the check is harmless and changes nothing. A request with an Origin header on such an endpoint is itself a signal worth logging.

Where an Origin check still leaks

MistakeWhat you seeFix
Substring or prefix matchingA lookalike domain connects and receives data.Compare the whole serialized origin against a set, and put hostile values like the four above in the tests.
A wildcard such as *.example.comAny subdomain, including a forgotten or takeover-prone one, gets a cookie-authenticated connection.List exact origins. If you need a pattern, treat every subdomain that can serve user content as hostile.
Treating null as friendlyA sandboxed iframe or a document generated from a data: URL connects. MDN lists both among the cases where Origin serializes to null.Never put null in the list.
Bearer token in the query stringThe token lands in access logs and proxy logs; the OWASP sheet warns about exactly this.Send it in a subprotocol or in the first message, and redact whatever you cannot avoid logging.
Dev origins left in the production listhttp://localhost:3000 is accepted from any user’s machine.Build the list from environment-specific config, and log the origin of every refusal.
Checking after the upgradeYou close the socket on the first message, but the server already sent data.Refuse in the HTTP response, before handleUpgrade.

Reproduce the attack

mkdir origin-try && cd origin-try && npm init -y >/dev/null && npm i [email protected]
# copy server.mjs from the lab (gate function above, plus an http server and ws.handleUpgrade)
MODE=gate PORT=4100 node server.mjs &
mkdir other && echo '<script>
const ws = new WebSocket("ws://localhost:4100/ws");
ws.onopen = () => document.title = "OPEN"; ws.onclose = (e) => document.title = "CLOSED " + e.code;
</script>' > other/index.html
python3 -m http.server 4200 -d other &
# open http://localhost:4200/ in a browser; the server log prints the Origin it saw and the decision

With MODE=none and a cookie from http://localhost:4100/login, the same page connects. With MODE=gate it is refused. For the Node side, new WebSocket(url, ['bearer.tok-bob-native'], { headers: { Origin: 'http://localhost:4100' } }) with the ws package shows the forged header.

What the lab confirmed, and what it did not

Verified on this machine, with Node.js 22.23.3, ws 8.22.0 and headless Chrome 154:

  • The cookie-session hijack from a page on a different port, and its refusal by the exact allow-list.
  • A Node client forging an allowed Origin: 401 without a cookie, success with one.
  • The gate function’s seven cases above, including the sandboxed iframe that sent Origin: null.
  • The three string-matching implementations against four hostile values (node --test, all passing).
  • The RFC 6455 and MDN statements above, read from the RFC text and MDN pages.

Not verified:

  • Other browsers. I used Chrome only.
  • Cross-subdomain hijacking, and whether a given proxy or CDN forwards or rewrites Origin. Check yours with a request that logs the headers it receives.
  • wss:// with real certificates. The lab used plain ws:// on localhost.
  • Any claim about native app attestation.

The ws server is a minimal one written for the experiment, not a production setup. The secrets in it (sid-alice, tok-bob-native) are made up.

What I would ship

Think of Origin as the browser telling your server which website is speaking through it. That makes it the right tool against hostile websites, and it is silent about everyone who is not a browser. Check it exactly, refuse on a mismatch, and give every connection an explicit credential the browser does not add on its own.