aio-libs/aiohttp · error · HTTPBadRequest
Handshake error
Error message
Handshake error: {key!r} What it means
Branch 1 of the handshake-key check (web_ws.py:317): if the Sec-WebSocket-Key header is missing/falsy OR its base64-decoded form is not exactly 16 bytes, aiohttp returns HTTP 400. Per RFC 6455 §4.1, the client must send a 16-byte random value base64-encoded in this header. A missing or wrong-length key means the handshake is invalid.
Solutions
- On the client, generate a correct key: import os, base64; key = base64.b64encode(os.urandom(16)).decode(). The aiohttp client and browsers do this automatically.
- Guard with ws.can_prepare(request) and return a friendly 400 instead of letting the framework's default error render.
- If you control the client, prefer aiohttp.ClientSession().ws_connect(url) over a hand-built handshake.
Example fix
// before — hand-built client key // headers['Sec-WebSocket-Key'] = 'fixed-test-key' # wrong length // after import os, base64 headers['Sec-WebSocket-Key'] = base64.b64encode(os.urandom(16)).decode()
Defensive patterns
Strategy: validation
Validate before calling
import base64, binascii
def valid_ws_key(request) -> bool:
key = request.headers.get('Sec-WebSocket-Key')
if not key:
return False
try:
return len(base64.b64decode(key)) == 16
except binascii.Error:
return False
if not valid_ws_key(request):
return web.Response(status=400, text='invalid Sec-WebSocket-Key') Type guard
import base64, binascii
def is_well_formed_ws_key(key: str | None) -> bool:
if not isinstance(key, str) or not key:
return False
try:
return len(base64.b64decode(key, validate=True)) == 16
except (binascii.Error, ValueError):
return False Try / catch
ws = web.WebSocketResponse()
try:
await ws.prepare(request)
except web.HTTPBadRequest as e:
if 'Handshake error' in (e.text or ''):
log.warning('client sent malformed Sec-WebSocket-Key')
return Prevention
- Generate keys on the client with base64.b64encode(os.urandom(16)).
- Prefer aiohttp.ClientSession.ws_connect over hand-built handshakes.
- Treat handshake 400s as expected for malformed/probe traffic.
When it happens
Trigger: Client omits Sec-WebSocket-Key; client sends a key that base64-decodes to fewer/more than 16 bytes (e.g. a fixed test string); a proxy that strips or rewrites the header; a malformed probe request.
Common situations: Hand-crafted test requests that reuse a non-16-byte key; bots/scanners probing WS endpoints; intermediaries that rewrite headers; clients built on broken libraries.
Understand the failure class
- SSL/TLS and certificate errors — how TLS handshakes and certificate validation fail.
Related errors
AI-assisted analysis of aio-libs/aiohttp@d041d4d0fd (2026-08-11).
Data as JSON: /api/errors/bd09d47da219a05f.
Report an issue: GitHub.
Appendix: source
Thrown at aiohttp/web_ws.py:317
else:
# No overlap found: Return no protocol as per spec
ws_logger.warning(
"%s: Client protocols %r don’t overlap server-known ones %r",
request.remote,
req_protocols,
self._protocols,
)
# check supported version
version = headers.get(hdrs.SEC_WEBSOCKET_VERSION, "")
if version not in ("13", "8", "7"):
raise HTTPBadRequest(text=f"Unsupported version: {version}")
# check client handshake for validity
key = headers.get(hdrs.SEC_WEBSOCKET_KEY)
try:
if not key or len(base64.b64decode(key)) != 16:
raise HTTPBadRequest(text=f"Handshake error: {key!r}")
except binascii.Error:
raise HTTPBadRequest(text=f"Handshake error: {key!r}") from None
accept_val = base64.b64encode(
hashlib.sha1(key.encode() + WS_KEY).digest()
).decode()
response_headers = CIMultiDict(
{
hdrs.UPGRADE: "websocket",
hdrs.CONNECTION: "upgrade",
hdrs.SEC_WEBSOCKET_ACCEPT: accept_val,
}
)
notakeover = False
compress = 0
if self._compress:
extensions = headers.get(hdrs.SEC_WEBSOCKET_EXTENSIONS)View on GitHub (pinned to d041d4d0fd)