Skip to main content

Browsers and origins

The dashboard from the previous page works, but the listener it connects to will accept a connection from any web page on any site — including one you didn't write. It also has no way to tell who the connecting user is, because a browser has no credentials file to present.

Those are the two things a browser changes: which pages may open a connection, and how a connection proves its identity. Everything else about the connection is unchanged.

Restrict which pages may connect

A browser attaches an Origin header to the WebSocket handshake, naming the site the page was served from. The server can require that header to match.

In production Acme serves the dashboard from https://ops.acme.example and lists that. You can watch the mechanism work on your own machine without any DNS, because localhost counts as an origin like any other.

Point the listener at a local origin:

websocket {
listen: 127.0.0.1:8080
no_tls: true

allowed_origins [
"http://localhost:8000"
]
}

Serve dashboard.html from the previous page on that origin:

python3 -m http.server 8000

Open http://localhost:8000/dashboard.html and it connects. Now open the same file as http://127.0.0.1:8000/dashboard.html. Same file, same machine, same port — and the connection is refused, because 127.0.0.1 isn't the string localhost.

That's the rule in one experiment: the match is exact on scheme, host and port, and it's a string comparison, not a name resolution. Against allowed_origins ["http://localhost:8000"] the server answers:

Origin the browser sendsHandshake
http://localhost:8000101 Switching Protocols
http://127.0.0.1:8000403 Forbidden
https://localhost:8000403 Forbidden
http://localhost:8001403 Forbidden
http://localhost403 Forbidden

allowed_origins can also be written origins, origin, allowed_origin, allow_origins or allow_origin; they're aliases for the same option.

The last row is the one that catches people. A URL with no port means port 80 for http:// and 443 for https://, so https://ops.acme.example and https://ops.acme.example:8443 are different origins. Serving the same dashboard over both means listing both.

same_origin: true is the other form of the check: instead of a list, it requires the Origin header to match the host the request arrived on. It suits a setup where the page and the NATS endpoint are served from one hostname, and gets in the way as soon as they aren't.

Set both and a request has to pass both checks. Leave both unset — the default — and any origin is accepted.

The check is skipped when there's no Origin header

Leave the config exactly as above and connect with the CLI:

nats -s ws://127.0.0.1:8080 sub "orders.>"

It connects. http://localhost:8000 is the only origin allowed, and the CLI is not that origin — but it sends no Origin header at all, and the server only runs the check when the header is present.

That is not a bug, and it isn't something you can configure away. The Origin header is set by the browser, not by the person using it, which is exactly what makes it useful: a page on evil.example cannot lie about being https://ops.acme.example. Anything that isn't a browser sets its own headers, so an attacker writing a client simply omits it.

So allowed_origins answers one question: may this web page open a connection using a visitor's browser? It does not answer may this connection reach my subjects? — permissions do that, and they apply to browser and non-browser connections alike. Configure both. A dashboard user restricted to subscribe: orders.> is safe whether the request carried an Origin header or not.

Give the browser credentials

A browser has no filesystem. The credentials file that a service uses isn't available, and putting an account seed in front-end code hands it to anyone who opens the developer tools. Two approaches work.

A bearer JWT in the connection

In operator mode, the browser presents a JWT it was issued, and the user is marked as a bearer user so no nonce signature is required. The page never holds a signing key — only a token that expires.

Issuing those tokens is the job of whatever already authenticates your users. The decentralized auth page covers the user and account setup.

The other approach keeps the token out of JavaScript entirely. The HTTP server that authenticated the user sets a cookie with HttpOnly, so scripts on the page can't read it, and the browser attaches it to the WebSocket handshake. The NATS server reads the cookie and uses it as the credential:

websocket {
# Behind the TLS-terminating ingress from the next page: the browser
# reaches wss://nats.acme.example, the ingress speaks ws:// to here.
listen: 0.0.0.0:8080
no_tls: true

jwt_cookie: "acme_nats_jwt"

allowed_origins [
"https://ops.acme.example"
]
}

jwt_cookie only works in operator mode. The server needs something to validate the JWT against, so it refuses to start when the cookie is set and no trusted operator or key is configured:

trusted operators or trusted keys configuration is required for JWT
authentication via cookie "acme_nats_jwt"

Four cookie names can be configured, matching the four things a client can present:

SettingCookie holds
jwt_cookiea NATS user JWT
user_cookiethe user name
pass_cookiethe password
token_cookiethe auth token

jwt_cookie has always been available. The other three arrived in nats-server 2.11, so check the version you run before relying on them.

Each one is only consulted when the client didn't supply that field itself. A JWT in the CONNECT protocol wins over jwt_cookie; a user name in CONNECT wins over user_cookie.

This is the pattern to reach for when your web application already has a login. The session cookie your login sets is a credential the browser transmits automatically and JavaScript can't exfiltrate.

For a dashboard that's genuinely public and read-only, no_auth_user names a user that unauthenticated WebSocket connections bind to. Give it subscribe permission on exactly the subjects the page shows and nothing else. It doesn't work in operator mode.

Restrict a user to WebSocket

A credential that reaches the dashboard shouldn't also work from a shell on port 4222. allowed_connection_types binds a user to the transports it may use:

authorization {
users [
{ user: dashboard, password: s3cr3t, allowed_connection_types: ["WEBSOCKET"] }
]
}

The full value set, and the _WS variants for the other protocols, are covered in MQTT → Auth and clustering. For a leaf node that dials in over WebSocket the value is LEAFNODE_WS, which the last page uses.

Compression

compress: true offers the permessage-deflate WebSocket extension. It's negotiated per connection: a client that doesn't ask for it gets an uncompressed connection, and the setting costs nothing on those.

compress can also be written compression; they're the same option.

Where it's enabled, it trades CPU on both ends for bytes on the wire. Which way that comes out depends on how compressible your payloads are and how constrained the link is, so it's a measurement rather than a default — compare CPU and throughput on your own traffic before turning it on broadly.

Pitfalls

Treating allowed_origins as access control. It's evaluated only when an Origin header is present, so it constrains browsers and nothing else. A client that connects directly never sees the check. Permissions still do the real work.

Setting same_origin: true when the page and the endpoint differ. It requires the Origin header to match the host the request arrived on, so it fails as soon as the dashboard and the NATS endpoint sit on different hostnames — which is the normal case behind an ingress.

Serving the page from a host you didn't list. Scheme, host and port all have to match as strings — localhost and 127.0.0.1 are different origins even on one machine. Moving the dashboard to a different hostname, or adding a port, breaks the handshake for every user with a 403 until the list is updated.

Putting a seed or credentials file in front-end code. Anything the page can read, a visitor can read. Use a bearer JWT or a cookie the page can't see.

Giving the dashboard user publish permission. A page that only displays orders needs subscribe on orders.>. Publish rights on a credential that ships to browsers is a much larger surface than the feature needs.

Where you are

The dashboard is now a controlled client:

  • only pages from https://ops.acme.example may open a connection
  • the browser presents a JWT from an HttpOnly cookie rather than holding a key
  • its user is restricted to WEBSOCKET connections
  • you know the origin check constrains browsers and not the port

What's next

Everything so far has run with no_tls: true on localhost, which means the cookie holding a user's JWT crosses the network in the clear. TLS and proxies fixes that, both with a certificate on the listener and with TLS terminated at the ingress that already fronts Acme's web estate.

See also