How to Handle WebSocket Authentication in Go

Web
Implement WebSocket authentication in Go by validating credentials during the HTTP handshake before allowing the connection upgrade.

The handshake is your only chance

You build a live dashboard that pushes stock prices to the browser. The frontend opens a WebSocket connection. The backend starts streaming JSON. Everything works until a malicious actor scripts a connection and floods your server with fake trades. You realize too late that the WebSocket upgrade bypassed your login route. The connection is now an open pipe with no identity attached.

Go does not include a built-in WebSocket authentication layer. The language treats WebSockets as a protocol upgrade over HTTP. That means you must verify credentials during the initial HTTP request, before the server switches protocols. Once the upgrade completes, HTTP headers disappear. You get exactly one chance to authenticate.

The upgrade is a one-way door. Check credentials before you cross it.

Why WebSocket auth happens before the upgrade

A WebSocket connection begins as a standard HTTP request. The client sends two special headers: Upgrade: websocket and Connection: Upgrade. The server reads those headers, runs its normal request pipeline, and responds with status code 101 Switching Protocols. After that response, the underlying TCP socket stops speaking HTTP and starts speaking the WebSocket framing protocol.

Think of it like a concert venue. The HTTP handshake is the ticket scanner at the entrance. The WebSocket connection is the stage once you are inside. If the scanner does not validate your ticket, you walk straight onto the stage. There is no second checkpoint. The server cannot ask for a password after the connection is already open because the WebSocket protocol does not carry HTTP headers.

Go solves this by letting you intercept the request before the upgrade happens. You write a small middleware function that checks a token or session cookie. If the credentials are valid, the middleware passes control to the WebSocket handler. If they are invalid, the middleware returns a 401 Unauthorized response and the upgrade never happens. The TCP connection closes cleanly. The client never gets a WebSocket socket.

The minimal middleware pattern

Here is the simplest middleware pattern that blocks unauthenticated upgrades:

package main

import (
    "net/http"
)

// AuthMiddleware wraps an http.Handler and checks the Authorization header.
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" || token != "valid-secret-token" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

The middleware returns a new http.Handler that captures the original handler in a closure. When a request arrives, it pulls the Authorization header. If the header is missing or does not match the expected value, it writes a 401 status and stops execution. The return statement prevents the call to next.ServeHTTP. If the token matches, control flows to the next handler in the chain, which is where your WebSocket library will perform the actual upgrade.

Middleware runs first. The WebSocket handler runs last.

What actually happens under the hood

Understanding the exact sequence prevents subtle bugs. The request travels through the Go standard library in a predictable order.

First, the client sends an HTTP GET request to /ws with the Upgrade headers. The net/http router matches the path and invokes your middleware chain. Your AuthMiddleware executes. It reads the header, validates the token, and calls next.ServeHTTP. At this point, the response writer has not been touched. The status code is still 200 by default.

Second, the WebSocket handler receives the request. It calls the upgrade function from a library like gorilla/websocket or nhooyr.io/websocket. The upgrade function checks that the response writer has not been written to yet. It verifies the Sec-WebSocket-Key header, generates a response hash, and writes the 101 status. The underlying TCP connection is hijacked. HTTP routing stops. The connection is now a raw WebSocket stream.

Third, your application reads and writes WebSocket frames. The original HTTP request object is still available in memory, but its headers are no longer useful for routing or authentication. Any user identity you need must be extracted during step one and stored somewhere the WebSocket handler can reach.

HTTP is the gatekeeper. WebSocket is the tunnel.

Real-world: JWT validation with context

Production systems rarely compare tokens to a hardcoded string. They decode JSON Web Tokens, verify signatures, and attach user claims to the request. Go handles this cleanly with the context package. You store the authenticated user inside the request context, then retrieve it later in the WebSocket handler.

Here is how you attach claims to the context and pass them through the upgrade:

package main

import (
    "context"
    "net/http"
)

// ContextKey holds the type-safe key for storing user data.
type ContextKey string

const UserKey ContextKey = "user"

// AuthMiddleware validates a JWT and stores the payload in the context.
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Missing token", http.StatusUnauthorized)
            return
        }

        user, err := validateJWT(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        // Attach user to context so downstream handlers can access it.
        ctx := context.WithValue(r.Context(), UserKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// validateJWT parses and verifies the token signature.
func validateJWT(token string) (string, error) {
    if token != "valid-jwt-string" {
        return "", fmt.Errorf("invalid signature")
    }
    return "alice@example.com", nil
}

The middleware creates a new context with context.WithValue. It passes a modified request to the next handler using r.WithContext(ctx). The WebSocket handler can now call r.Context().Value(UserKey) to retrieve the email address. This pattern keeps authentication logic separate from message routing logic. It also respects the Go convention that context.Context always travels as the first parameter in any function that might need cancellation or request-scoped data. Functions that accept a context should respect deadlines and cancellation signals.

Context carries state. The connection carries bytes.

Common traps and compiler/runtime errors

Several mistakes trip up developers who are used to REST APIs. The first is trying to authenticate after the upgrade. If you wait until the WebSocket is open to check a token, you have to invent a custom application-level handshake. That adds latency, complicates error handling, and leaves the connection open to abuse during the verification window.

The second mistake is mixing HTTP response writes with the WebSocket upgrade. If your middleware or an earlier handler calls w.Write() or w.WriteHeader(), the WebSocket library will panic. The upgrade requires a pristine response writer. The compiler will not catch this because it is a runtime condition. You will see a panic message like http: superfluous response.WriteHeader call or websocket: close sent while upgrading. Always ensure authentication fails fast with http.Error and a return before any downstream code touches the writer.

The third trap is forgetting to check for the Upgrade header before running expensive validation. Database lookups or cryptographic signature checks take time. If you run them on every request, including static asset fetches or health checks, your server slows down. Add a quick guard clause that only runs authentication when r.Header.Get("Upgrade") == "websocket".

Go conventions help avoid these issues. The if err != nil { return err } pattern is verbose by design. It forces you to acknowledge every failure path instead of swallowing errors. Use it consistently in your validation logic. Also, name your receiver variables with one or two letters that match the type. Write (m *AuthMiddleware) ServeHTTP instead of (this *AuthMiddleware). The community expects it, and it keeps the code scannable. Public names start with a capital letter. Private start lowercase. There are no public or private keywords. The compiler enforces visibility through capitalization alone.

Validate early. Upgrade late. Never mix the two.

When to use which auth strategy

Authentication strategies depend on your deployment environment and client type. Pick the approach that matches your threat model.

Use header-based tokens when your clients are single-page applications or mobile apps that can securely store credentials. The Authorization header is standard, easy to inspect in middleware, and works cleanly with CORS preflight requests.

Use cookie-based sessions when you need automatic credential rotation or want to protect against cross-site request forgery. Browsers attach cookies automatically, but you must set SameSite and Secure flags correctly to prevent leakage.

Use query parameter authentication only for legacy integrations or server-to-server connections where headers are stripped by proxies. Query parameters appear in server logs and browser history, so they are less secure for user-facing applications.

Use mutual TLS when you are building infrastructure services or internal microservices that communicate over a private network. Certificate validation happens at the transport layer, so your HTTP middleware never needs to inspect tokens.

Skip WebSocket authentication when the connection is purely internal and the network is already isolated. Extra validation adds latency and complexity that you do not need in a trusted environment.

Pick the transport that matches your threat model.

Where to go next