How to Implement GraphQL Authentication in Go

Web
Configure Go module authentication by setting the GOAUTH environment variable to a credential command like git or netrc.

The missing lock on your GraphQL door

You build a GraphQL endpoint. It resolves queries perfectly in your terminal. Then you push it to staging and realize anyone with a browser can fetch your entire user table. You forgot to verify who is asking. GraphQL does not handle authentication by default. It simply executes whatever resolver functions you provide. You have to wire the identity check into the request lifecycle yourself.

How the pipeline actually works

Authentication in Go follows a predictable flow. The HTTP server receives a raw request. A middleware function inspects the Authorization header. If the credentials are valid, the middleware attaches the user identity to the request context. The context travels downward through the handler chain until it reaches your GraphQL router. Your resolvers read the identity from the context and decide which data to return.

The context acts like a secure backpack that moves with every request. You pack the user ID at the top of the stack. You unpack it at the bottom. Nothing else touches it. This design keeps authentication logic separate from business logic. Your resolvers focus on data transformation. Your middleware focuses on security boundaries. The two never leak into each other.

Context values are immutable. Calling context.WithValue does not modify the original context. It returns a new context that wraps the old one. This guarantees that concurrent requests never share state. Each request gets its own isolated backpack. The garbage collector cleans up the chain when the response finishes.

The context is a backpack. Pack it at the top. Unpack it at the bottom.

The middleware that starts the chain

Here's the raw middleware that intercepts every request before it reaches your GraphQL handler.

package main

import (
	"context"
	"net/http"
)

// UserKey is a custom type to prevent context key collisions.
type UserKey struct{}

// AuthMiddleware validates the bearer token and attaches it to the context.
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Pull the raw token string from the HTTP header.
		token := r.Header.Get("Authorization")
		if token == "" {
			// Stop the chain early if credentials are missing.
			http.Error(w, "missing token", http.StatusUnauthorized)
			return
		}
		// Create a new context that carries the token forward.
		ctx := context.WithValue(r.Context(), UserKey{}, token)
		// Replace the request context and pass control to the next handler.
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Middleware runs once per request. Keep it fast and keep it simple.

Following the request through the stack

When a client sends a POST request to your /graphql endpoint, the Go standard library routes it through the handler chain. AuthMiddleware executes first. It reads the header, validates the token, and builds a new context. The r.WithContext(ctx) call creates a shallow copy of the request struct with the updated context field. The original request remains untouched. This is crucial for safety. If you mutated the original request, concurrent requests sharing the same underlying connection pool could accidentally see each other's credentials.

The enriched request flows into your GraphQL server. Libraries like gqlgen or graphql-go extract the context from the request and pass it as the first argument to every resolver. Go convention dictates that context.Context always appears as the first parameter, conventionally named ctx. Functions that accept a context should respect cancellation and deadlines. If the client disconnects or the server hits a timeout, the context signals the cancellation. Your resolver should check ctx.Err() before doing expensive work.

The context flows downward. Data flows upward. Never mutate the original.

Reading identity inside a resolver

Here's how a resolver reads that context and gates access to sensitive fields.

package main

import (
	"context"
	"errors"
)

// GetCurrentUser extracts the authenticated token from the request context.
func GetCurrentUser(ctx context.Context) (string, error) {
	// Retrieve the value using the typed key we defined earlier.
	val := ctx.Value(UserKey{})
	if val == nil {
		// Return a clear error when the context lacks user data.
		return "", errors.New("unauthenticated")
	}
	// Assert the stored value back to its original string type.
	token, ok := val.(string)
	if !ok {
		// Fail safely if another package stored a different type.
		return "", errors.New("invalid token type in context")
	}
	return token, nil
}

Type assertions are your safety net. Check the boolean before you dereference.

Where things go wrong

Context values are untyped at runtime. ctx.Value returns an any. If you forget the type assertion, you get a runtime panic when you try to use the value as a string or struct. The compiler will not catch this. It only knows you are calling a method that returns any. You must verify the type yourself. The double-value return from type assertions exists for this exact reason. Always check the ok boolean.

Another common mistake is using string keys for context values. If you pass "user" as a key, another package in your dependency tree might also use "user". The context lookup will return the wrong value. The compiler rejects this pattern with a lint warning in modern tooling, but it is not a hard error. Define a custom struct type for every context key. The zero value of a struct is unique to your package. Collisions become impossible.

Storing large objects in context blocks the request lifecycle. The context lives until the response finishes. If you attach a 50-megabyte configuration blob to the context, that memory stays pinned for the entire request duration. Under load, this causes allocation pressure and triggers garbage collection pauses. Keep context values small. Store identifiers, tokens, or pointers. Fetch heavy data on demand.

A missing context check is a silent data leak. Verify early. Fail loudly.

Picking the right boundary

Authentication strategy depends on where you want to enforce the rule. Different architectures place the check at different layers. Choose the layer that matches your threat model and performance requirements.

Use HTTP middleware when you need to validate tokens before the request touches your GraphQL router. Use resolver-level context checks when different fields require different permission levels. Use a dedicated auth library when you need to handle JWT rotation, refresh tokens, or OAuth flows. Use plain header inspection when you are building an internal tool that trusts the network boundary.

Authentication is a boundary problem. Draw the line where it makes sense.

Where to go next