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_originsand the request'sOriginmatches an entry. Requests without anOriginheader pass through without CORS processing. A disallowedOriginis never refused: the relay answers it without CORS headers, so a browser cannot read the answer, while curl and native clients are unaffected. cors_originsentries match the requestOriginby 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.comcompares the hostname only, needs a real subdomain (app.example.commatches,example.comandnotexample.comdo not), and accepts any scheme.https://*.example.comneeds the scheme to match and applies the same hostname rule, sohttp://app.example.comdoes 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 addsVary: Origin.Vary: Originappears only with the CORS headers. Access-Control-Allow-Credentials: trueis sent unlesscors_allow_credentialsisfalse.Access-Control-Allow-Methodsdefaults toGET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD;cors_methodsreplaces it.Access-Control-Allow-Headersdefaults toContent-Type, Authorization, X-MTE-Relay-Route;cors_allowed_headersreplaces it.Access-Control-Expose-Headersdefaults toContent-Type;cors_exposed_headersreplaces it. The relay always appendsX-MTE-Relay-Route,X-MTE-Relay-Error,X-MTE-Relay-Request-Id,X-MTE-Relay-Config-Version, andRetry-After.Access-Control-Max-Ageis sent whencors_max_age_secondsis above 0.- The relay answers
OPTIONSpreflights with204itself. Withcors_preflight_passthroughon, 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 gets404, 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:
- A custom
cors_allowed_headerswithoutContent-TypeandX-MTE-Relay-Route. List every header name the browser sends in the clear as well, such as names in the SDK'sunencryptedHeaders. Browsers ignore a*inAccess-Control-Allow-Headerson credentialed requests, so list names explicitly. cors_allow_credentials: falsewhile the SDK sends credentials. The browser engine sends every relay request withcredentials: "include"by default. Either keep the relay default, or configure the SDK withcredentials: "same-origin"or"omit".- 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:
| Request | Method and path | Body |
|---|---|---|
| Auth and discovery | GET /api/mte-relay?frameVersions=2, plus clientId when refreshing | none |
| Pairing | POST /api/mte-pair | JSON |
| Keepalive | POST /api/mte-keepalive | JSON |
| Encrypted request | POST /, from the SDKs | binary, Content-Type: application/octet-stream |
| Health check | GET or HEAD on HEALTH_CHECK_PATH when it is set, otherwise GET /api/mte-echo or HEAD /api/mte-echo | none |
| Pass through route | as the client sent it | plain |
Rules for anything in that path:
- Allow
application/octet-streamrequest 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_BODYrule 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 carryX-Accel-Buffering: noandCache-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-Errorheader. 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 incomingX-Forwarded-Protofrom 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-Routekeeps 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, andGETandHEADat that path answer200{"status":"ready"}while the relay serves, or503{"status":"not_ready"}once shutdown begins. It is matched before Host resolution, CORS, rate limits, and the access log, so anyHost, a raw IP, and a malformedHostall get the answer.HEADsends no body. No phase, no reason, no version. Any other method falls through to normal routing, so aPOSTto 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 apass_through_routesentry,GETandHEADget the health answer and other methods still reach the origin, and on an outbound domain a local application'sGETto 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-echoorHEAD /api/mte-echo, which answers{"message":"true","timestamp":"2026-01-04T10:30:45.123456789Z"}.?msg=testmakes"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.