# Upgrade Header

> Learn how the Upgrade header requests protocol upgrades to WebSocket, HTTP/2, or other protocols on the same TCP connection. Understand upgrade negotiation.

Source: https://howhttpworks.com/headers/upgrade
Last reviewed: 2026-10-05

> **TL;DR:** `Upgrade` requests an HTTP/1.1 protocol switch on the same connection. WebSocket uses `Upgrade: websocket` plus `Connection: Upgrade` and a validated `101` response. The old `h2c` upgrade to HTTP/2 is obsolete in RFC 9113.

A client offers one or more protocols; the server may accept with `101 Switching Protocols` or continue responding in HTTP. `Upgrade` applies only to the immediate hop. [RFC 9110 §7.8](https://www.rfc-editor.org/rfc/rfc9110#section-7.8) requires its sender to name it in `Connection`.

## How Upgrade Works

This illustrative WebSocket exchange uses the test key and matching accept value from [RFC 6455 §1.3](https://www.rfc-editor.org/rfc/rfc6455#section-1.3):

```http
GET /chat HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
```

```http
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
```

After the blank line ending the response headers, traffic uses WebSocket frames, not an HTTP response body. An HTTP 200 response does not establish this WebSocket connection.

## Syntax

```text
Upgrade: protocol-name[/protocol-version], another-protocol
```

Protocol names and versions come from the chosen protocol specification; arbitrary strings do not make a server support a new protocol.

## WebSocket Upgrade

The server must validate the opening handshake, including a key that decodes to 16 bytes and version `13`. Browser requests include `Origin`, which a server can check against permitted page origins. If a server selects a subprotocol, it must be one the client offered. See [RFC 6455 §4.2](https://www.rfc-editor.org/rfc/rfc6455#section-4.2).

## HTTP/2 Upgrade

RFC 7540 described `Upgrade: h2c` and `HTTP2-Settings`. [RFC 9113 §3.1](https://www.rfc-editor.org/rfc/rfc9113#section-3.1) removes that mechanism; §§11.1–11.2 mark the header and token obsolete. Current HTTP/2 uses TLS ALPN or prior knowledge for cleartext connections.

## Server Implementation

For nginx proxying to an HTTP/1.1 WebSocket server at `127.0.0.1:3000`, place this in an existing `server` block:

```nginx
location /chat {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
```

nginx does not forward hop-by-hop headers automatically. Its [WebSocket proxying documentation](https://nginx.org/en/docs/http/websocket.html) explains why they must be supplied explicitly. By default, nginx closes this proxied connection if the upstream sends no data for 60 seconds; `proxy_read_timeout` changes that wait, and server ping frames can keep traffic flowing.

### Node.js with ws

For a working local echo endpoint, install the protocol library with `pnpm add ws`. Save as `echo.mjs` and run `node echo.mjs`:

```javascript
import http from 'node:http'
import { WebSocketServer } from 'ws'

const server = http.createServer((req, res) => {
  res.writeHead(404)
  res.end()
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
  const path = new URL(req.url, 'http://localhost').pathname
  if (path !== '/chat') {
    socket.end('HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
    return
  }
  const origin = req.headers.origin
  if (origin && origin !== 'http://localhost:3000') {
    socket.end('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
    return
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req))
})
wss.on('connection', (ws) => {
  ws.on('error', console.error)
  ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }))
})
server.listen(3000, '127.0.0.1')
```

`handleUpgrade()` validates the handshake and retains bytes already read in `head`. The library also handles masking and frame parsing. The endpoint allows a missing Origin for non-browser diagnostic clients; that policy is not authentication. For a private endpoint, verify the session before calling `handleUpgrade()`. See [ws's upgrade and authentication examples](https://github.com/websockets/ws#client-authentication).

### Go with gorilla/websocket

In a Go module, install `github.com/gorilla/websocket` with `go get github.com/gorilla/websocket`. This alternate local server keeps the Upgrader's default origin check:

```go
package main

import (
    "log"
    "net/http"
    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{}

func main() {
    http.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil { return }
        defer conn.Close()
        for {
            kind, data, err := conn.ReadMessage()
            if err != nil { return }
            if err := conn.WriteMessage(kind, data); err != nil { return }
        }
    })
    log.Fatal(http.ListenAndServe("127.0.0.1:3000", nil))
}
```

When Origin is present, the [default policy](https://pkg.go.dev/github.com/gorilla/websocket#hdr-Origin_Considerations) rejects an Origin host different from the request Host. If a frontend on another hostname needs access, define a narrow `CheckOrigin` policy for that hostname. Returning `true` for every request removes the check.

### Python with aiohttp

Install `aiohttp` in your Python environment and save this alternative echo server as `echo.py`. It handles both text and binary messages:

```python
from aiohttp import web, WSMsgType

async def chat(request):
    origin = request.headers.get('Origin')
    if origin and origin != 'http://localhost:3000':
        raise web.HTTPForbidden()
    socket = web.WebSocketResponse()
    await socket.prepare(request)
    async for message in socket:
        if message.type == WSMsgType.TEXT:
            await socket.send_str(message.data)
        elif message.type == WSMsgType.BINARY:
            await socket.send_bytes(message.data)
        elif message.type == WSMsgType.ERROR:
            print(socket.exception())
    return socket

app = web.Application()
app.router.add_get('/chat', chat)
web.run_app(app, host='127.0.0.1', port=3000)
```

Run `python echo.py` instead of the Node or Go server. `prepare()` performs the opening handshake; after it succeeds, use WebSocket send methods rather than writing an HTTP response body. The [aiohttp server guide](https://docs.aiohttp.org/en/stable/web_quickstart.html#websockets) documents this lifecycle. `WSMsgType` belongs to `aiohttp`, not `aiohttp.web`.

### Caddy reverse proxy

Caddy handles WebSocket upgrades through its ordinary reverse proxy. For a local test, save this as `Caddyfile`:

```text
:8080 {
    reverse_proxy 127.0.0.1:3000
}
```

Run `caddy run --config Caddyfile` and connect the client to `ws://localhost:8080/chat`. The backend still receives `/chat`; no path rewrite is configured. Unlike the nginx example, this does not require manually supplying Upgrade and Connection. Caddy's [reverse proxy documentation](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#streaming) describes the switch to a bidirectional tunnel. A browser request still carries its page Origin, so account for that origin in the backend's allowlist.

## Client Implementation

In a browser, use the WebSocket API; it generates the handshake:

```javascript
const socket = new WebSocket('wss://example.com/chat')
socket.addEventListener('open', () => socket.send('hello'))
socket.addEventListener('message', (event) => console.log(event.data))
```

The constructor accepts a URL and optional subprotocols, not arbitrary request headers. See the [WebSockets Standard](https://websockets.spec.whatwg.org/#the-websocket-interface).

For the local echo server, a Node client can send headers that the browser constructor cannot. Save this as `client.mjs` after installing `ws`:

```javascript
import WebSocket from 'ws'

const socket = new WebSocket('ws://127.0.0.1:3000/chat')
socket.on('error', console.error)
socket.on('open', () => socket.send('diagnostic echo'))
socket.on('message', (data) => {
  console.log(data.toString())
  socket.close()
})
```

A Python client provides another independent check. Install `websockets` in your Python environment, then run:

```python
import asyncio
from websockets.asyncio.client import connect

async def main():
    async with connect('ws://127.0.0.1:3000/chat') as socket:
        await socket.send('diagnostic echo')
        print(await socket.recv())

asyncio.run(main())
```

The [async context manager](https://websockets.readthedocs.io/en/latest/reference/asyncio/client.html) closes the connection on exit. If these clients work but the browser does not, compare its Origin and offered subprotocols with server policy. An Origin check can distinguish permitted browser pages; non-browser clients can supply that field themselves.

## Security Considerations

An accept hash confirms handshake processing, not user authentication. Authorize access before accepting the connection, and use `wss://` for TLS. Do not copy a server example that unconditionally accepts every `Origin` if access depends on browser cookies.

For ws, ping/pong can detect a connection that disappeared without a close frame. Add this inside the `connection` handler; the 30-second interval is a chosen application setting:

```javascript
let alive = true
ws.on('pong', () => { alive = true })
const heartbeat = setInterval(() => {
  if (!alive) {
    ws.terminate()
    return
  }
  alive = false
  ws.ping()
}, 30000)
ws.on('close', () => clearInterval(heartbeat))
```

The [ws heartbeat example](https://github.com/websockets/ws#how-to-detect-and-close-broken-connections) uses this pattern. Align the interval with the proxy's no-data timeout. A client ping alone may not satisfy a timer that specifically waits for data from the upstream server; make sure server responses or server pings traverse that leg.

## Getting Upgrade right

Parse `Connection` as comma-separated tokens rather than checking whether a string contains the substring `upgrade`. `notupgrade` is not the `Upgrade` token. For a Node upgrade handler, this checks the token list:

```javascript
const hasUpgradeToken = (req.headers.connection ?? '')
  .split(',')
  .some((token) => token.trim().toLowerCase() === 'upgrade')
```

Reject a missing token before switching protocols. This checks one field only; let the WebSocket library validate the remaining handshake, including the key and version. A substring check can accept `notupgrade` and conceal a malformed request.

## Common Issues

`Upgrade: WebSocket` is not inherently wrong: RFC 6455 matches this value case-insensitively. Missing companion headers, a stripped key, or a normal HTTP response are reasons to inspect both sides of the proxy.

To inspect a handshake against your own local endpoint:

```bash
curl --http1.1 -i -N --max-time 5 \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://localhost:3000/chat
```

This uses a fixed diagnostic key, not a production client's random key. The bounded command inspects the response; it is not an interactive WebSocket application.

## HTTP/2 Considerations

Do not forward `Connection` or `Upgrade` into HTTP/2 or HTTP/3 messages. WebSocket over HTTP/2 uses extended CONNECT, defined separately in [RFC 8441](https://www.rfc-editor.org/rfc/rfc8441), rather than this HTTP/1.1 exchange.

## Related Headers

- [Connection](https://howhttpworks.com/headers/connection)
- [Sec-WebSocket-Key](https://howhttpworks.com/headers/sec-websocket-key)
- [Sec-WebSocket-Accept](https://howhttpworks.com/headers/sec-websocket-accept)
- [SSE vs WebSockets vs long polling](https://howhttpworks.com/compare/sse-vs-websockets)
