Skip to main content
Version: 5.x

CORS and WAF

CORS for browser clients​

A browser page calling a relay on another origin needs CORS. The relay answers CORS itself, per domain, from DOMAIN_MAP:

  • CORS headers are added only when the domain sets cors_origins and the request's Origin matches an entry. Requests without an Origin header pass through without CORS processing. A disallowed Origin is never refused: the relay answers it without CORS headers, so a browser cannot read the answer, while curl and native clients are unaffected.
  • cors_origins entries match the request Origin by these rules, the same rules SocketX applies to its origin list:
    • Comparison is case insensitive on both sides.
    • * allows every origin.
    • An exact origin such as https://app.example.com, with a port if the browser sends one, compares the whole string.
    • *.example.com compares the hostname only, needs a real subdomain (app.example.com matches, example.com and notexample.com do not), and accepts any scheme.
    • https://*.example.com needs the scheme to match and applies the same hostname rule, so http://app.example.com does not match it. The v5 betas ignored the scheme in this form.
    • A malformed origin (one with a path, query, fragment, or userinfo, or the literal null) never matches a wildcard entry.
  • The relay echoes the request's origin in Access-Control-Allow-Origin, never a literal *, and adds Vary: Origin. Vary: Origin appears only with the CORS headers.
  • Access-Control-Allow-Credentials: true is sent unless cors_allow_credentials is false.
  • Access-Control-Allow-Methods defaults to GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD; cors_methods replaces it.
  • Access-Control-Allow-Headers defaults to Content-Type, Authorization, X-MTE-Relay-Route; cors_allowed_headers replaces it.
  • Access-Control-Expose-Headers defaults to Content-Type; cors_exposed_headers replaces it. The relay always appends X-MTE-Relay-Route, X-MTE-Relay-Error, X-MTE-Relay-Request-Id, X-MTE-Relay-Config-Version, and Retry-After.
  • Access-Control-Max-Age is sent when cors_max_age_seconds is above 0.
  • The relay answers OPTIONS preflights with 204 itself. With cors_preflight_passthrough on, preflights on pass through routes go to the upstream and preflights on an outbound domain go to the next relay, but a preflight to the encrypted route then gets 404, which breaks browser clients.

Encrypted requests are POST with Content-Type: application/octet-stream, which is not a CORS safelisted content type, so a browser preflights every cross origin encrypted request.

Three things break browser clients when a domain customizes CORS:

  1. A custom cors_allowed_headers without Content-Type and X-MTE-Relay-Route. List every header name the browser sends in the clear as well, such as names in the SDK's unencryptedHeaders. Browsers ignore a * in Access-Control-Allow-Headers on credentialed requests, so list names explicitly.
  2. cors_allow_credentials: false while the SDK sends credentials. The browser engine sends every relay request with credentials: "include" by default. Either keep the relay default, or configure the SDK with credentials: "same-origin" or "omit".
  3. An origin missing from cors_origins, including a different scheme or port.

Cookies​

With forward_browser_cookies on, the default, the relay copies the browser's Cookie header upstream when the encrypted frame carries none, and moves upstream Set-Cookie headers onto its real HTTP response, the only place a browser stores them from. The relay does not rewrite a cookie's Domain or Path, so the cookie must be valid for the relay host. Cookies ride cross origin requests only when the request is credentialed and the relay allows credentials.

WAF, CDN, and proxy notes​

This is the traffic a device in front of the relay sees:

RequestMethod and pathBody
Auth and discoveryGET /api/mte-relay?frameVersions=2, plus clientId when refreshingnone
PairingPOST /api/mte-pairJSON
KeepalivePOST /api/mte-keepaliveJSON
Encrypted requestPOST /, from the SDKsbinary, Content-Type: application/octet-stream
Health checkGET or HEAD on HEALTH_CHECK_PATH when it is set, otherwise GET /api/mte-echo or HEAD /api/mte-echonone
Pass through routeas the client sent itplain

Rules for anything in that path:

  • Allow application/octet-stream request bodies on the relay host. A content type allow list without it blocks every encrypted request.
  • Expect ciphertext. Body inspection sees random bytes, not application content, so it cannot match on the encrypted route. Exclude that route from body rules if they fire on it.
  • Allow bodies over 8 KB. Pairing requests that fill a client's pool are about 14 KB with Kyber 1024, and encrypted requests grow with the content. On AWS WAF, the core rule set's SizeRestrictions_BODY rule blocks every body over 8 KB; set it to Count. A WAF answer carries no CORS headers, so a browser reports the block as a CORS error. See Production architecture.
  • Do not buffer or transform responses. Framed responses are binary, chunked, and carry no Content-Length. They carry X-Accel-Buffering: no and Cache-Control: no-store, no-transform, private.
  • Do not retry encrypted requests. Each frame advances MTE state, so a replayed request is refused with 479 replay_rejected.
  • Keep the X-MTE-Relay-Error header. If a device replaces an error body, clients still read the code and reason from the header.
  • Terminate TLS in front of the relay. The relay serves plain HTTP on LISTEN_PORT. It uses an incoming X-Forwarded-Proto from the load balancer when it stamps forwarding headers upstream.
  • Read status codes with the frame in mind. A relayed response travels as HTTP 200, because the origin's status travels inside the RESPONSE frame. Relay errors travel as 470 to 490, 500, 502, and 503.
  • Route by client if you scale out. A load balancer that hashes on X-MTE-Relay-Route keeps each client on one replica. See Limits and scaling.

Health checks and scanners​

A load balancer or a security scanner that can only reach the relay port has two routes that answer for any Host, outside every rate limit:

  • HEALTH_CHECK_PATH, the first choice. It is off by default. Set it to a path such as /health, and GET and HEAD at that path answer 200 {"status":"ready"} while the relay serves, or 503 {"status":"not_ready"} once shutdown begins. It is matched before Host resolution, CORS, rate limits, and the access log, so any Host, a raw IP, and a malformed Host all get the answer. HEAD sends no body. No phase, no reason, no version. Any other method falls through to normal routing, so a POST to the same path still reaches a pass through route. Set it in the environment or in the control plane, where it is a Server group live field. Pick a path the origin does not use: if it equals a pass_through_routes entry, GET and HEAD get the health answer and other methods still reach the origin, and on an outbound domain a local application's GET to that path gets the relay's answer instead of the next hop's. The validation rules list what a path may contain.
  • GET /api/mte-echo or HEAD /api/mte-echo, which answers {"message":"true","timestamp":"2026-01-04T10:30:45.123456789Z"}. ?msg=test makes "message":"test", and the message is always a JSON string.

Both answers carry Content-Type: application/json, Cache-Control: no-store, no-transform, private, and X-Content-Type-Options: nosniff; the echo adds X-MTE-Relay-Request-Id. Neither route is logged at info or published in discovery.

Security scanners such as Invicti send a preflight request to the scan target URL and stop on a 4xx or 5xx, and a plain GET / answers 404 or 480 by design. Point the scanner at HEALTH_CHECK_PATH or at /api/mte-echo. A 429 style refusal also stops a scan, which is why both routes sit outside every rate limiter. Expect an "HSTS not enabled" finding: the relay listens on plain HTTP behind the TLS terminator, and HSTS belongs on that terminator. Behind a load balancer set TRUSTED_PROXIES, or the scanner and real users share one discovery rate limit.