Authentication#
The WebSocket protocol was designed for creating web applications that need bidirectional communication between clients running in browsers and servers.
In most practical use cases, WebSocket servers need to authenticate clients in order to route communications appropriately and securely.
RFC 6455 stays elusive when it comes to authentication:
This protocol doesn’t prescribe any particular way that servers can authenticate clients during the WebSocket handshake. The WebSocket server can use any client authentication mechanism available to a generic HTTP server, such as cookies, HTTP authentication, or TLS authentication.
None of these three mechanisms works well in practice. Using cookies is cumbersome, HTTP authentication isn’t supported by all mainstream browsers, and TLS authentication in a browser is an esoteric user experience.
Fortunately, there are better alternatives! Let’s discuss them.
System design#
Consider a setup where the WebSocket server is separate from the HTTP server.
Most servers built with websockets to complement a web application adopt this design because websockets doesn’t aim at supporting HTTP.
The following diagram illustrates the authentication flow.
Assuming the current user is authenticated with the HTTP server (1), the application needs to obtain credentials from the HTTP server (2) in order to send them to the WebSocket server (3), who can check them against the database of user accounts (4).
Usernames and passwords aren’t a good choice of credentials here, if only because passwords aren’t available in clear text in the database.
Tokens linked to user accounts are a better choice. These tokens must be impossible to forge by an attacker. For additional security, they can be short-lived or even single-use.
Sending credentials#
Assume the web application obtained authentication credentials, likely a token, from the HTTP server. There’s four options for passing them to the WebSocket server.
Sending credentials as the first message in the WebSocket connection.
This is fully reliable and the most secure mechanism in this discussion. It has two minor downsides:
Authentication is performed at the application layer. Ideally, it would be managed at the protocol layer.
Authentication is performed after the WebSocket handshake, making it impossible to monitor authentication failures with HTTP response codes.
Adding credentials to the WebSocket URI in a query parameter.
This is also fully reliable but less secure. Indeed, it has a major downside:
URIs end up in logs, which leaks credentials. Even if that risk could be lowered with single-use tokens, it is usually considered unacceptable.
Authentication is still performed at the application layer but it can happen before the WebSocket handshake, which improves separation of concerns and enables responding to authentication failures with HTTP 401.
Setting a cookie on the domain of the WebSocket URI.
Cookies are undoubtedly the most common and hardened mechanism for sending credentials from a web application to a server. In an HTTP application, credentials would be a session identifier or a serialized, signed session.
Unfortunately, when the WebSocket server runs on a different domain from the web application, this idea bumps into the Same-Origin Policy. For security reasons, setting a cookie on a different origin is impossible.
The proper workaround consists in:
creating a hidden iframe served from the domain of the WebSocket server
sending the token to the iframe with postMessage
setting the cookie in the iframe
before opening the WebSocket connection.
Sharing a parent domain (e.g. example.com) between the HTTP server (e.g. www.example.com) and the WebSocket server (e.g. ws.example.com) and setting the cookie on that parent domain would work too.
However, the cookie would be shared with all subdomains of the parent domain. For a cookie containing credentials, this is unacceptable.
Adding credentials to the WebSocket URI in user information.
Letting the browser perform HTTP Basic Auth is a nice idea in theory.
In practice it doesn’t work due to poor support in browsers.
As of May 2021:
Chrome 90 behaves as expected.
Firefox 88 caches credentials too aggressively.
When connecting again to the same server with new credentials, it reuses the old credentials, which may be expired, resulting in an HTTP 401. Then the next connection succeeds. Perhaps errors clear the cache.
When tokens are short-lived or single-use, this bug produces an interesting effect: every other WebSocket connection fails.
Safari 14 ignores credentials entirely.
Two other options are off the table:
Setting a custom HTTP header
This would be the most elegant mechanism, solving all issues with the options discussed above.
Unfortunately, it doesn’t work because the WebSocket API doesn’t support setting custom headers.
Authenticating with a TLS certificate
While this is suggested by the RFC, installing a TLS certificate is too far from the mainstream experience of browser users. This could make sense in high security contexts. I hope developers working on such projects don’t take security advice from the documentation of random open source projects.
Let’s experiment!#
The experiments/authentication directory demonstrates these techniques.
Run the experiment in an environment where websockets is installed:
$ python experiments/authentication/app.py
Running on http://localhost:8000/
When you browse to the HTTP server at http://localhost:8000/ and you submit a username, the server creates a token and returns a testing web page.
This page opens WebSocket connections to four WebSocket servers running on four different origins. It attempts to authenticate with the token in four different ways.
First message#
As soon as the connection is open, the client sends a message containing the token:
const websocket = new WebSocket("ws://.../");
websocket.onopen = () => websocket.send(token);
// ...
At the beginning of the connection handler, the server receives this message and authenticates the user. If authentication fails, the server closes the connection:
async def first_message_handler(websocket):
token = await websocket.recv()
user = get_user(token)
if user is None:
await websocket.close(CloseCode.INTERNAL_ERROR, "authentication failed")
return
...
Query parameter#
The client adds the token to the WebSocket URI in a query parameter before opening the connection:
const uri = `ws://.../?token=${token}`;
const websocket = new WebSocket(uri);
// ...
The server intercepts the HTTP request, extracts the token and authenticates the user. If authentication fails, it returns an HTTP 401:
class QueryParamProtocol(websockets.WebSocketServerProtocol):
async def process_request(self, path, headers):
token = get_query_parameter(path, "token")
if token is None:
return http.HTTPStatus.UNAUTHORIZED, [], b"Missing token\n"
user = get_user(token)
if user is None:
return http.HTTPStatus.UNAUTHORIZED, [], b"Invalid token\n"
self.user = user
async def query_param_handler(websocket):
user = websocket.user
...
User information#
The client adds the token to the WebSocket URI in user information before opening the connection:
const uri = `ws://token:${token}@.../`;
const websocket = new WebSocket(uri);
// ...
Since HTTP Basic Auth is designed to accept a username and a password rather
than a token, we send token
as username and the token as password.
The server intercepts the HTTP request, extracts the token and authenticates the user. If authentication fails, it returns an HTTP 401:
class UserInfoProtocol(websockets.BasicAuthWebSocketServerProtocol):
async def check_credentials(self, username, password):
if username != "token":
return False
user = get_user(password)
if user is None:
return False
self.user = user
return True
async def user_info_handler(websocket):
user = websocket.user
...
Machine-to-machine authentication#
When the WebSocket client is a standalone program rather than a script running in a browser, there are far fewer constraints. HTTP Authentication is the best solution in this scenario.
To authenticate a websockets client with HTTP Basic Authentication (RFC 7617), include the credentials in the URI:
async with websockets.connect(
f"wss://{username}:{password}@example.com",
) as websocket:
...
(You must quote()
username
and password
if they
contain unsafe characters.)
To authenticate a websockets client with HTTP Bearer Authentication
(RFC 6750), add a suitable Authorization
header:
async with websockets.connect(
"wss://example.com",
extra_headers={"Authorization": f"Bearer {token}"}
) as websocket:
...