Secure WebSocket Traffic
Configure Apache APISIX to authenticate a WebSocket opening handshake with an API key before upgrading and proxying the connection.
Applies to:Apache APISIX 3.18.0
Roles:Platform engineerSecurity engineerTime:15 minutesImpact:No restart, no traffic change
Goal
A WebSocket route that rejects unauthenticated opening handshakes, accepts a valid API key in a request header, removes that credential before proxying, and then maintains a bidirectional upgraded connection to a sample echo server.
Before you begin
- You have Docker, cURL, OpenSSL, and websocat.
- You have an Apache APISIX installation on the
apisix-quickstart-netDocker network. Get APISIX creates this local environment. - The container name
websocket-server, host port8080, consumerws-john, credential IDcred-ws-john-key-auth, route IDws-route, and path/.wsare unused.
WebSocket authentication happens in the initial HTTP Upgrade request. Command-line clients such as websocat can add a header to that request, but the browser WebSocket API does not let application code set arbitrary headers. Do not move a long-lived API key into a URL query string merely to work around that browser restriction: URLs can appear in histories, access logs, metrics, and referrers. Browser applications need a deliberately designed cookie, short-lived ticket, or subprotocol-based handshake scheme appropriate to their threat model.
Set the variables and generate a guide-only credential:
export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_WS_URL="ws://127.0.0.1:9080"
read -rsp "Admin API key: " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo
WS_API_KEY="$(openssl rand -hex 24)" && export WS_API_KEYCheck the APISIX identifiers before creating anything:
for path in consumers/ws-john routes/ws-route; do
curl -s -o /dev/null -w "${path}: %{http_code}\n" \
"${APISIX_ADMIN_URL}/apisix/admin/${path}" \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}"
done
docker ps -a --filter 'name=^/websocket-server$' --format '{{.Names}}'Expected: both APISIX lines end in 404 and the Docker command has no output. If any target exists, stop and choose isolated names rather than overwriting it.
Steps
Four steps start the sample server, create a consumer, attach a key credential, and expose an authenticated WebSocket route.
Step 1: Start the WebSocket echo server
Start the sample server on the APISIX quickstart network:
docker run -d \
-p 8080:8080 \
--name websocket-server \
--network apisix-quickstart-net \
jmalloc/echo-serverExpected: Docker returns a container ID, and docker ps --filter 'name=^/websocket-server$' shows the container as running.
What changed: a guide-only upstream now exposes its echo WebSocket endpoint at /.ws inside the APISIX network.
Step 2: Create the consumer
Create a separate consumer identity for the WebSocket client:
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"username": "ws-john"
}
EOF{ "value": { "username": "ws-john" } }What changed: APISIX has a consumer identity, but it cannot authenticate until it has a credential.
Step 3: Attach the key credential
Attach the generated key to the consumer:
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers/ws-john/credentials" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<EOF
{
"id": "cred-ws-john-key-auth",
"plugins": {
"key-auth": {"key": "${WS_API_KEY}"}
}
}
EOF{ "value": { "id": "cred-ws-john-key-auth", "plugins": { "key-auth": { … } } } }What changed: the consumer can authenticate with a high-entropy guide-only API key.
Step 4: Create the authenticated WebSocket route
Enable WebSocket proxying, require the key, and remove it before the request reaches the upstream:
curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/ws-route" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"uri": "/.ws",
"enable_websocket": true,
"plugins": {
"key-auth": {"hide_credentials": true}
},
"upstream": {
"type": "roundrobin",
"nodes": {"websocket-server:8080": 1}
}
}
EOF{ "value": { "id": "ws-route", "uri": "/.ws", "enable_websocket": true, "plugins": { "key-auth": { "hide_credentials": true } }, … } }What changed: APISIX authenticates the opening handshake, strips the apikey header, and upgrades successful requests to a proxied WebSocket connection.
Validate
Attempt the connection without a credential:
websocat "${GATEWAY_WS_URL}/.ws"Expected: the opening handshake fails with 401 Unauthorized; no WebSocket connection is established.
Connect with the generated credential:
websocat "${GATEWAY_WS_URL}/.ws" -H "apikey: ${WS_API_KEY}"Expected: the server announces that it is ready. Enter hello and press Return; the next line is hello. Enter another value to confirm the same upgraded connection remains bidirectional, then press Control-C to close it.
Try an invalid key:
websocat "${GATEWAY_WS_URL}/.ws" -H 'apikey: invalid'Expected: the opening handshake fails with 401 Unauthorized.
Roll back / Clean up
Delete dependent APISIX objects in reverse order, then remove the sample container:
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/ws-route" \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE \
"${APISIX_ADMIN_URL}/apisix/admin/consumers/ws-john/credentials/cred-ws-john-key-auth" \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/ws-john" \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}"
docker rm -f websocket-server
unset WS_API_KEY APISIX_ADMIN_KEYExpected: each Admin API deletion succeeds, Docker prints websocket-server, and a new connection to ${GATEWAY_WS_URL}/.ws no longer matches this route.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
websocat receives 401 with the generated key. | The header name or value differs from the credential, or the credential belongs to another consumer. | Use -H "apikey: ${WS_API_KEY}" and inspect the ws-john credential through the Admin API. |
The handshake returns 502 or closes immediately. | APISIX cannot resolve or reach websocket-server:8080. | Confirm both containers use apisix-quickstart-net and the echo container is running. |
| The request reaches the upstream as ordinary HTTP. | enable_websocket is absent or the client did not request an Upgrade. | Read back ws-route, confirm enable_websocket is true, and use a WebSocket client rather than cURL. |
A browser cannot send the apikey header. | The browser WebSocket API intentionally lacks arbitrary-header support. | Design a browser-appropriate short-lived handshake credential; do not expose the long-lived API key in the URL. |
Related
- Implement Key Authentication — understand consumers, credentials, and credential-hiding behavior.
- Proxy WebSocket Connections (opens in API7 Gateway docs) — configure WebSocket proxying without this authentication layer.
key-authplugin reference (opens in Plugin Hub docs) — customize the key location, realm, and credential removal.limit-connplugin reference (opens in Plugin Hub docs) — limit concurrent upgraded connections after authentication.