This document proposes adding a WebSocket constructor that can take custom headers for the opening WebSocket connection and additionally a WebSocket specific CORS preflight when cross-origin.
A WebSocket connection begins with an HTTP opening handshake. Although that handshake can include ordinary HTTP request headers, the browser WebSocket API lets script provide only a URL and optional subprotocol names.
This proposal lets applications add non-forbidden request headers to the opening handshake. Additionally, for cross-origin connections, the browser performs a CORS preflight with an explicit WebSocket opt-in before sending non-allowlisted headers.
The motivating use case is for authenticating WebSocket connections using application supplied OAuth bearer tokens, such as the ones used by some AI chat web interfaces.
WebSocket authentication workarounds can expose credentials, slow connections, and delay useful failures. Putting credentials in a URL makes them more likely to be saved in server logs or monitoring systems. Requesting a separate short-lived token adds latency to initial connections and reconnections. Authenticating only after the WebSocket opens makes users wait longer to learn that their credentials were rejected. It also requires using authentication libraries in ways that are different from typical HTTP requests.
Today a caller might try to embed an OAuth token in the URL:
const socket = new WebSocket(
`wss://chat.example/connect?access_token=\${encodeURIComponent(token)}`);
URLs are commonly recorded by servers, proxies, and monitoring systems, so this workaround can expose the credential outside the authentication system.
With the proposal a caller could do the following:
const socket = new WebSocket("wss://chat.example/connect", {
headers: {
Authorization: `Bearer ${token}`,
},
});
And instead use the authorization header in their opening HTTP request.
A WebSocket connection begins with an HTTP opening handshake, before it can exchange WebSocket messages. The server can accept that HTTP request and create the two-way WebSocket connection, or reject it. This is a useful point to authenticate because the server can use the same identity and security systems that protect its usual HTTP endpoints rather than creating additional authentication mechanisms specifically for WebSocket connections.
In RFC 6455 The WebSocket Protocol (page 19, bullet 12) it permits additional header fields, including authentication-related fields, in the opening handshake and allows a server to use authentication mechanisms available to an HTTP server.
Several widely used non-browser WebSocket clients let applications configure request headers. For example, .NET provides ClientWebSocketOptions.SetRequestHeader, and Python's websockets library accepts additional_headers.
The browser WebSocket API does not expose that capability to script. The current WebSocket interface exposes only a URL and optional subprotocol names on the WebSocket constructor, for example:
const socket = new WebSocket(url, ["chat.v1"]);
HTTP uses the Authorization request header to carry credentials for an authentication scheme. For example, in the motivating use case an OAuth bearer token:
Authorization: Bearer eyJ...
With the current WebSocket constructor there is no way to configure the Authorization header or any other custom request headers for the opening handshake.
Allowing browser script to add Authorization or other application-selected headers without a permission check (CORS preflight) would provide websites with new capabilities to send cross origin requests.
Today, a cross-origin WebSocket server can rely on an important browser constraint: page script cannot attach an Authorization header or arbitrary application headers to its opening handshake. Although this is allowed by the WebSocket standard, services deployed and tested only for use with WebSocket via the browser may by relying on this browser constraint. Additionally, the HTTP/1.1 WebSocket opening handshake looks like a normal GET request to a server that is unaware of WebSocket, so the ability to add custom headers to the WebSocket opening handshake would become a send-only CORS bypass.
If browsers removed the constraint unconditionally, a malicious page could send requests to those services. The browser therefore needs to ask the destination first, without sending the credential. Only a destination that recognizes the new capability and explicitly permits the requesting origin, the WebSocket operation, and the requested header names should be sent the credential-bearing handshake.
Accordingly, this proposal requires a preflight for cross-origin headers outside CORS's small safelist of simple request fields. The extra request is not intended to authenticate the user. It protects existing servers from receiving a class of browser-generated request that they could not previously receive.
Browser applications use several workarounds today:
Some of these workarounds can be appropriate in a particular service, while current OAuth guidance rules out bearer tokens in URL query parameters. None provides a general way to send an application-selected request header during the opening handshake. The alternatives section describes their tradeoffs in detail.
The proposal should:
This proposal does NOT aim to:
No formal user study has been completed for this draft.
WHATWG WebSockets issue #16 has tracked requests for custom opening-handshake headers since 2017. The discussion includes authentication, API gateways, standardized subprotocols, Kubernetes, reverse proxies, and large real-time services.
A maintainer of a large Microsoft 365 WebSocket service described the operational tradeoffs publicly in a June 2026 comment. The service authenticates after opening the WebSocket today, but considers that path more complex, more expensive when denying users, and inconsistent with native clients. The same comment identifies pre-connection ticket allocation and query-string credentials as unsatisfactory alternatives.
This evidence comes from deployed systems and public implementation discussions rather than formal user research.
Server-side and native environments commonly expose opening-handshake headers. Recent JavaScript runtime examples include:
These APIs demonstrate demand and a familiar authoring shape. They are not a sufficient browser security model because trusted server-side code does not face the same cross-origin request-forgery constraints as untrusted web content.
Extend the existing constructor's second argument to accept an options dictionary, for example:
const socket = new WebSocket("wss://realtime.example/connect", {
protocols: ["chat.v1"],
headers: {
Authorization: makeAuthorizationValue(accessToken),
},
});
Existing calls remain valid:
new WebSocket(url);
new WebSocket(url, "chat.v1");
new WebSocket(url, ["chat.v1", "chat.v2"]);
The browser converts headers using rules compatible with the existing HeadersInit that we are reusing (see Fetch Standard 5.1 Headers class). It silently removes headers with names or values that are disallowed according to the "request" headers guard in the same way as the Request constructor does. This means that forbidden headers are disallowed.
Note that this API shape is provisional. It depends on resolving the compatibility issue tracked in pull request #76 - Add WebSocketInit dictionary as option for second param in constructor. If this proposal is not accepted, then we can amend this document to propose a constructor with a third optional headers parameter. For example:
const socket = new WebSocket(
"wss://realtime.example/connect",
["chat.v1"],
{ Authorization: makeAuthorizationValue(accessToken) }
});
The Fetch Standard's forbidden request-header rules apply as described above regarding the request headers guard. If a forbidden request header or value is supplied in the headers parameter, it is silently removed by the browser during WebSocket object construction.
Additionally the CORS Safelisted request headers rules apply. If the request is cross-origin and the caller has provided request headers outside of the CORS-safelisted request-headers then we must use a CORS preflight check.
Before sending the credential-bearing opening handshake, the browser performs a WebSocket-specific CORS preflight.
The preflight request identifies:
The destination must explicitly allow:
In order to ensure servers are explicitly allowing WebSocket and giving them the ability to apply different rules for WebSocket requests, the proposal introduces dedicated preflight headers as follows.
Request:
Access-Control-Request-Protocol: websocket
and response:
Access-Control-Allow-Protocols: websocket
The Access-Control-Request-Method header will be set to GET regardless of the underlying HTTP version used for the WebSocket handshake.
Otherwise, the normal CORS rules are applied.
Note, for Authorization, the response must name the field explicitly rather than rely on Access-Control-Allow-Headers: *. Fetch already specifies that the wildcard does not cover Authorization, but issue #1919 records that major browsers do not enforce that rule interoperably today. This proposal should not inherit the accidental wildcard behavior.
For CORS preflight caching we need to ensure that WebSocket CORS requests and responses are properly separated from ordinary HTTP CORS requests and responses.
A server that wants to receive an application-controlled Authorization header:
An unchanged server does not return the new opt-in. The browser therefore does not send the credential-bearing handshake.
The Fetch changes should reuse the existing CORS-preflight cache rather than define a parallel WebSocket cache. The CORS preflight cache key must additionally include the WebSocket-specific permission to ensure that permissions for WebSocket connections and non-WebSocket connections are not mixed.
Implementations should reuse the preflight's network connection for the opening handshake when the HTTP version, proxy, connection coalescing rules, and security policy allow it. Reuse cannot be guaranteed in every deployment, but the design should not unnecessarily require a second connection.
For example, if the following script creates a cross-origin WebSocket like in the following then the browser would send a WebSocket specific CORS preflight request:
const socket = new WebSocket("wss://realtime.example/connect", {
protocols: ["chat.v1"],
headers: {
Authorization: makeAuthorizationValue(accessToken),
},
});
WebSocket specific CORS preflight request. Note that it has the Access-Control-Request-Protocol: websocket header to note that this is a WebSocket specific CORS preflight and no Access-Control-Request-Method header, but otherwise is a normal CORS preflight request.
OPTIONS /connect HTTP/1.1
Host: chat.example
Origin: https://app.example
Access-Control-Request-Protocol: websocket
Access-Control-Request-Headers: authorization
The server may allow the CORS preflight request with the following. Note that it is a normal CORS preflight except for the Access-Control-Allow-Protocols: websocket header to indicate this is WebSocket specific.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Protocols: websocket
Access-Control-Allow-Headers: authorization
And then finally send the normal WebSocket opening handshake request but with the authorization header:
GET /connect HTTP/1.1
Host: chat.example
Origin: https://app.example
Authorization: Bearer \<token\>
Upgrade: websocket
Connection: Upgrade
...
Note that the WebSocket subprotocol is not involved in the CORS preflight. The subprotocol is negotiated during the opening handshake and is not relevant to the CORS preflight.
The permission grants a WebSocket opening operation, not a literal GET or CONNECT chosen before connection establishment.
This lets the browser perform a preflight over one HTTP version and later establish the WebSocket over another without ambiguous method permission. The eventual Fetch and WebSockets specification text must define this precisely.
If validation fails, or the destination does not grant permission, the constructor or connection fails using the WebSocket API's existing error model. The application-controlled credential is not sent without the required consent.
This proposal does not expose the HTTP status or response headers from a failed opening handshake. WHATWG WebSockets issue #77 now tracks that related capability, including proxy throttling, authentication challenges, and explicit server exposure. It should proceed as a parallel proposal with its own cross-origin information-exposure analysis rather than being implied by permission to send request headers.
The page-facing failure remains intentionally limited, but implementations should provide developer-only diagnostics that distinguish at least:
Those diagnostics must not expose credentials or cross-origin response details that the page is not permitted to observe. Server operators should likewise be able to identify and safely log the preflight decision without logging the credential value.
Servers may disallow the CORS preflight in the usual ways. It can return a 4xx status, for example:
HTTP/1.1 403 Forbidden
Content-Length: 0
Or reply with a response that excludes the authorization header, for example:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Protocols: websocket
A server unaware of WebSocket specific CORS preflight may reply with a normal CORS preflight response but without the new and unrecognized Access-Control-Request-Protocol header. The browser would consider this a rejection.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Headers: authorization
All of the above CORS preflight failures are exposed to the caller via the existing WebSocket error event with no new way to distinguish a CORS preflight failure from other opening handshake network failure.
For callers that must write script that supports both browsers without this feature and with this feature we need a mechanism to detect the feature support.
If we provide no explicit mechanism, the caller could detect feature support with an accessor property on the options object (or for the third parameter) to see if the browser attempts to read the headers property. For example:
function supportsWebSocketRequestHeaders() {
let supported = false;
const probeException = {};
try {
new WebSocket("data:,", {
get headers() {
supported = true;
throw probeException;
},
});
} catch { }
return supported;
}
However, this is not intuitive and requires the browser implementation to not fail early before reading the headers.
We can add an explicit mechanism to determine support with a static supports method. For example:
if (WebSocket.supports?.("request-headers")) {
// Use new custom headers
} else {
// Use legacy flow
}
The proposal depends on two pieces that do not yet have standards consensus:
Implementations should not ship without the header capability.
The server can open the WebSocket before checking the application's authentication data, then require the client to authenticate in its first message. A simplified client might do this:
const socket = new WebSocket("wss://realtime.example/connect");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ type: "authenticate", token }));
});
This remains a viable workaround, but it does not meet the goal of authenticating the HTTP opening handshake or unifying browser and native clients.
The application can make an authenticated HTTPS request that returns a short-lived, single-use token for the WebSocket connection:
This can be appropriate for individual services, but the platform should not require every service to build an allocation protocol to use standard HTTP authentication.
const socket = new WebSocket(
`wss://realtime.example/connect?access_token=${encodeURIComponent(token)}`
);
OAuth 2 security guidance specifically addresses this risk. RFC 6750 section 2.3 warns against URI query parameters because URLs containing access tokens are likely to be logged. RFC 9700 section 4.3.2 strengthens this guidance: clients must not pass access tokens in a URI query parameter in the manner described by RFC 6750.
Bearer credentials should have a standard, recognizable transport with established handling and redaction practices. URL query parameters are not an acceptable long-term default.
The optional second constructor argument controls Sec-WebSocket-Protocol. Some applications encode credentials in a subprotocol value because it is one of the few opening-handshake values that the constructor exposes to script.
The subprotocol parameter should continue to name application protocols. It should not become a credential transport.
This remains a possible longer-term direction, especially for failed-response visibility and stream integration. It should not block a compatible extension to the existing API.
WebTransport should be chosen for its transport properties, not as a workaround for a missing WebSocket authentication capability.
The proposal changes network connection setup and has no direct user-interface semantics.
The proposal adds no user-facing text or locale-sensitive processing. Header names remain ASCII as defined by HTTP. Applications remain responsible for localizing any authentication or connection error shown to users.
Moving bearer tokens out of URLs reduces accidental disclosure through URL logging, tracing, diagnostics, and telemetry. See Alternatives Considered section for more information.
The primary security invariant is a browser must not send an application-controlled header outside CORS's safelist in a cross-origin WebSocket opening handshake unless the destination explicitly permits the requesting origin, the WebSocket operation, and the header name.
Additional requirements include:
The proposal intentionally requires server changes. A server that has not evaluated this capability should continue to deny it by default.
What are the exact preflight request and response fields and structured values?
How should the CORS preflight cache be modified to account for WebSocket-specific headers?
Should we have an explicit feature detection mechanism (eg a static WebSocket.supports method) or rely on getter-style probing?
Should we add a third headers parameter to the WebSocket ctor, or require also changing the WebSocket ctor to take an options object? This proposal is currently mostly agnostic and describes how to modify either form of constructor.
The browser WebSocket API does not expose the ability to set headers from script. The current WebSocket interface exposes only a URL and optional subprotocol names on the WebSocket constructor:
constructor(USVString url,
optional (DOMString or sequence<DOMString>) protocols = []);
The proposed WebSocket constructor would be extend the existing constructor's second argument to accept an options dictionary:
dictionary WebSocketInit {
sequence<DOMString> protocols = [];
HeadersInit headers;
};
[Exposed=(Window,Worker)]
interface WebSocket : EventTarget {
constructor(
USVString url,
optional (DOMString or sequence<DOMString> or WebSocketInit)
protocolsOrOptions = []
);
static boolean supports(DOMString feature);
};
Or if we don’t have an options parameter then a third parameter for custom headers like in the following
Note that this API shape is provisional. It depends on resolving the compatibility issue tracked in pull request #76 - Add WebSocketInit dictionary as option for second param in constructor. If this proposal is not accepted, then we can amend this document to propose a constructor with a third optional headers parameter. For example:
[Exposed=(Window,Worker)]
interface WebSocket : EventTarget {
constructor(
USVString url,
optional (DOMString or sequence<DOMString>) protocols,
optional HeadersInit headers
);
static boolean supports(DOMString feature);
};