WebTransport sits in an odd spot for people who already know CORS.
You expect the usual fetch() rules, preflights, and response headers. Then you try WebTransport over HTTP/3 and realize the model is related to CORS, but not the same shape. Browsers still care about origin-based access control, but WebTransport uses its own handshake rules instead of classic OPTIONS preflight.
If you build browser-facing infrastructure, this distinction matters. A lot.
The short version
For WebTransport:
- Browsers send an
Originheader on the session request. - Your server must decide whether to allow that origin.
- The server signals permission with
Sec-Webtransport-Http3-Draft02: 1and a successful response to the CONNECT-style request, depending on the stack and draft/version support. - Classic CORS headers like
Access-Control-Allow-MethodsandAccess-Control-Allow-Headersare generally not the center of the protocol here. - You still need a strict allowlist. Treat
Originas mandatory security input.
So if you’re looking for OPTIONS examples, you’re solving the wrong problem.
What “CORS for WebTransport” really means
With fetch, CORS is mostly a response-header protocol:
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: ETag, Link
GitHub’s API is a classic example of a broad public CORS response:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s useful context because many teams assume WebTransport should behave the same way. It doesn’t.
For WebTransport, the browser still protects users from cross-origin abuse, but the main server-side job is:
- Read the
Originheader. - Compare it against an allowlist.
- Accept or reject the session.
Think of it as origin authorization during the transport handshake, not “CORS headers for a resource response”.
When you need origin checks
If your browser app at:
https://app.example
connects to:
https://wt.example:4433/chat
that is cross-origin unless scheme, host, and port all match exactly.
So this needs explicit server approval.
These are different origins:
https://app.examplehttps://www.app.examplehttps://app.example:8443http://app.example
Do exact matching. No substring checks. No regex unless you really know what you’re doing.
Bad:
if (origin.includes("example.com")) allow();
That accepts https://evil-example.com and https://example.com.attacker.test.
Good:
const allowed = new Set([
"https://app.example",
"https://admin.example",
]);
if (allowed.has(origin)) allow();
Browser-side example
The browser API is simple:
const transport = new WebTransport("https://wt.example.com/room");
await transport.ready;
console.log("connected");
const writer = transport.datagrams.writable.getWriter();
await writer.write(new TextEncoder().encode("hello"));
writer.releaseLock();
That code does not let you manually set Origin. The browser does that. Your server has to enforce policy.
Server-side policy: the part that actually matters
Your server should reject missing or unexpected origins.
Pseudocode:
const allowedOrigins = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
function isOriginAllowed(origin) {
return typeof origin === "string" && allowedOrigins.has(origin);
}
Then during the WebTransport session request:
function handleWebTransportSession(req, res) {
const origin = req.headers["origin"];
if (!isOriginAllowed(origin)) {
res.writeHead(403);
res.end("Forbidden origin");
return;
}
// Continue WebTransport negotiation in your HTTP/3 stack
}
That’s the core rule. Everything else is framework detail.
Node example with explicit origin enforcement
Node’s built-in support for WebTransport is still stack-dependent, so most real deployments use a proxy or a specialized HTTP/3 server in front. The policy logic still looks like this:
const allowedOrigins = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
function validateOrigin(req) {
const origin = req.headers.origin;
if (!origin) return false;
return allowedOrigins.has(origin);
}
function onSessionRequest(req, res) {
if (!validateOrigin(req)) {
res.writeHead(403, { "content-type": "text/plain" });
res.end("Forbidden");
return;
}
// Required by the WebTransport over HTTP/3 negotiation your stack supports
res.writeHead(200, {
"sec-webtransport-http3-draft02": "1",
});
// Hand off to WebTransport session implementation
}
Two opinions from painful experience:
- Reject requests with no
Originunless you have a very specific non-browser client story. - Log denied origins. You’ll want that data the first time staging breaks because someone used
https://app.example.com/with a slash in config.
Go example
Go makes it easy to keep this boring and correct.
package main
import (
"log"
"net/http"
)
var allowedOrigins = map[string]bool{
"https://app.example.com": true,
"https://staging.example.com": true,
}
func webTransportHandler(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if !allowedOrigins[origin] {
http.Error(w, "Forbidden origin", http.StatusForbidden)
return
}
// Depending on your HTTP/3/WebTransport library:
w.Header().Set("Sec-Webtransport-Http3-Draft02", "1")
w.WriteHeader(http.StatusOK)
// Upgrade/handshake continuation happens in the WebTransport-capable server stack
}
func main() {
http.HandleFunc("/wt", webTransportHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The exact handshake plumbing changes with the library. The origin check does not.
Reverse proxy pattern
A lot of teams terminate HTTP/3 at a proxy and send validated traffic upstream. That’s fine, but don’t assume the proxy’s existence means origin policy is handled.
If your proxy can inspect Origin, enforce it there too.
Example policy logic:
map $http_origin $wt_origin_allowed {
default 0;
"https://app.example.com" 1;
"https://staging.example.com" 1;
}
server {
listen 443 quic reuseport;
server_name wt.example.com;
location /wt {
if ($wt_origin_allowed = 0) {
return 403;
}
add_header Sec-Webtransport-Http3-Draft02 "1" always;
proxy_pass http://webtransport_backend;
}
}
I wouldn’t stop at proxy enforcement alone. Keep the backend check too. Defense in depth wins here because proxy config drifts all the time.
Do you need Access-Control-Allow-Origin?
Usually, for WebTransport handshake security, origin validation is the real requirement.
You may still emit Access-Control-Allow-Origin on related REST endpoints used by the same app, but don’t confuse that with WebTransport authorization.
If your service exposes both:
/api/*viafetch/wtvia WebTransport
then you might have both patterns side by side:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
for API responses, and
Sec-Webtransport-Http3-Draft02: 1
plus allowlist-based origin validation for WebTransport session requests.
Different mechanisms. Same security goal.
Credentials and cookies
If your WebTransport endpoint relies on cookies or authenticated browser state, be stricter than usual:
- Never use wildcard origin logic.
- Match exact origins.
- Validate session/auth state server-side.
- Consider CSRF-style abuse paths, because cross-origin browser requests are exactly where people get sloppy.
If you’re mixing cookies, session auth, and cross-origin transport, your cookie settings matter too. That drifts beyond CORS, but it’s part of the same security boundary. If you need a broader refresher on security headers, https://csp-guide.com is the one non-official reference I’d keep around.
Common mistakes
1. Expecting an OPTIONS preflight
WebTransport is not regular CORS preflight flow.
If you built a beautiful OPTIONS handler and the browser still refuses the session, that’s why.
2. Allowing *
For public fetch APIs, Access-Control-Allow-Origin: * can be fine, like GitHub’s API does:
access-control-allow-origin: *
For authenticated WebTransport sessions, wildcard thinking is how you create cross-origin abuse.
3. Trusting Host instead of Origin
Host tells you where the request went. Origin tells you where the browser script came from.
For browser access control, Origin is the signal you care about.
4. Using loose pattern matching
Don’t do this:
if (origin.endsWith("example.com")) { ... }
It may accidentally allow badexample.com depending on implementation.
Safer:
const allowed = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
5. Forgetting port numbers
https://app.example.com:4443 is not the same origin as https://app.example.com.
A practical checklist
Use this before shipping:
- WebTransport endpoint only accepts HTTPS over HTTP/3
- Server reads and validates
Origin - Exact-match allowlist only
- Missing
Originrejected unless explicitly needed - Session auth checked server-side
- Denied origins logged
- Proxy and app agree on origin policy
- REST CORS config kept separate from WebTransport handshake logic
Minimal copy-paste policy
If you just need the bare minimum logic, use this pattern:
const allowedOrigins = new Set([
"https://app.example.com",
]);
function enforceWebTransportOrigin(req, res) {
const origin = req.headers.origin;
if (!origin || !allowedOrigins.has(origin)) {
res.writeHead(403, { "content-type": "text/plain" });
res.end("Forbidden origin");
return false;
}
res.setHeader("Sec-Webtransport-Http3-Draft02", "1");
return true;
}
And use it in your session handler:
function handleSession(req, res) {
if (!enforceWebTransportOrigin(req, res)) return;
res.writeHead(200);
// continue WebTransport session setup
}
Official docs worth checking
For exact browser and protocol behavior, check the official docs and specs:
- MDN WebTransport documentation
- W3C WebTransport specification
- IETF HTTP/3 and WebTransport drafts/specification pages
The implementation details are still more awkward than plain old CORS, but the security rule is refreshingly simple: treat WebTransport as an origin-gated browser channel, and enforce that gate on every session request.