How to Use HTTP Basic Authentication in Go

Web
Use SetBasicAuth to send credentials or BasicAuth to validate them in Go HTTP requests and handlers.

The handshake nobody wants to see

You are building a small internal dashboard. It needs to pull metrics from a legacy monitoring service that expects a username and password. You do not want to roll your own token system. You just want to send the credentials and get a JSON response. HTTP Basic Authentication is the oldest trick in the book for this exact problem. It predates modern OAuth flows by decades, but it still works because it is simple, standardized, and built directly into the Go standard library.

What Basic Auth actually does

Basic Auth is not encryption. It is a structured way to pack a username and password into a single string, encode that string in Base64, and attach it to an HTTP request header. The server receives the header, decodes it, splits it at the colon, and checks the values against its own records. If the values match, the server sends back the requested data. If they do not match, the server responds with a 401 status code and a WWW-Authenticate header that tells the client to try again with credentials.

Think of it like a hotel key card. The card itself is not a vault. It just carries a number that the lock recognizes. Anyone who intercepts the card can read the number, but the lock only cares that the number matches. Base64 encoding is not a security measure. It is a transport format that guarantees the credentials will not break the HTTP header parser by containing spaces or control characters. The actual security comes from TLS. Without HTTPS, Basic Auth sends your password in plain text over the network. Base64 is formatting, not security. Trust TLS to protect the payload.

Attaching credentials on the client

The standard library handles the Base64 encoding and header formatting automatically. You do not need to import a third party package or manually construct the Authorization header. The http.Request type exposes a method that takes a username and password, encodes them, and sets the header for you.

Here is the simplest client request: create a request, attach credentials, and send it.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	// Create a GET request to the target endpoint
	req, err := http.NewRequest("GET", "https://api.example.com/metrics", nil)
	if err != nil {
		fmt.Println("failed to create request:", err)
		return
	}

	// Encode username and password into Base64 and set the Authorization header
	req.SetBasicAuth("admin", "s3cret")

	// Send the request and check the response status
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("request failed:", err)
		return
	}
	defer resp.Body.Close()

	fmt.Println("server responded with:", resp.Status)
}

The SetBasicAuth method does three things behind the scenes. It joins the username and password with a colon. It runs the combined string through a Base64 encoder. It writes the result into the Authorization: Basic <encoded-string> header. If you inspect the raw HTTP traffic, you will see Authorization: Basic YWRtaW46czNyZXQ=. The compiler will reject the program with cannot use string as int in argument if you accidentally pass the wrong types, but SetBasicAuth strictly expects two strings. The standard library handles the encoding. You just provide the strings.

Checking credentials on the server

The server side mirrors the client. The http.Request type provides a BasicAuth method that extracts the header, decodes the Base64 payload, and splits it back into two strings. The method returns three values: the username, the password, and a boolean indicating whether the header was present and valid.

Here is the simplest server handler: extract credentials, validate them, and respond.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	// Register a handler for the protected route
	http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
		// Extract and decode the Basic Auth header
		user, pass, ok := r.BasicAuth()

		// Check if the header exists and matches expected credentials
		if !ok || user != "admin" || pass != "s3cret" {
			// Return 401 and set the challenge header so browsers prompt for login
			w.Header().Set("WWW-Authenticate", `Basic realm="metrics"`)
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		fmt.Fprintln(w, "access granted")
	})

	fmt.Println("listening on :8080")
	http.ListenAndServe(":8080", nil)
}

The r.BasicAuth() call returns an empty string for both username and password if the header is missing or malformed. The boolean ok tells you whether the header was successfully parsed. If you forget to check ok and only compare the strings, your server will treat a missing header as an empty username and password, which usually fails validation but obscures the real problem. The convention in Go is to handle the unhappy path first. The if !ok || ... pattern makes the rejection logic visible and keeps the success path unindented. Handle the missing header first. Keep the success path flat.

Walking through the wire

When a browser or client hits a protected endpoint without credentials, the server responds with a 401 status code. The WWW-Authenticate header in that response is what triggers the browser login dialog. Without that header, the client just sees a 401 error page and has no idea what authentication scheme to use. Browsers expect the realm parameter in the header. It is a human readable string that tells the user which section of the site requires login.

On the second attempt, the browser automatically attaches the Authorization header with the encoded credentials. The server decodes it, validates it, and returns a 200 OK. The browser caches the credentials for the duration of the session or until the realm changes. If you change the password on the server, the browser will keep sending the old credentials until you clear the cache or the session expires. This caching behavior is why Basic Auth feels seamless in browsers but can be confusing during development. Browsers cache credentials aggressively. Clear the cache when debugging.

The Go standard library does not cache credentials on the server side. Every request triggers a fresh call to r.BasicAuth(). This means you can change the valid password in your code and restart the server, and the next request will immediately reflect the change. The tradeoff is that you are doing Base64 decoding on every single request. Base64 is computationally cheap, but if you are processing millions of requests per second, the repeated decoding adds up. Most applications never hit that ceiling, but it is worth knowing where the work happens.

Real world: protecting an API endpoint

Production code rarely compares passwords as plain strings. You hash the expected password, store the hash, and compare the incoming password against the hash. You also pass a context.Context through your handler chain so you can respect timeouts and cancellation. The context always goes as the first parameter, conventionally named ctx. Functions that accept a context should check ctx.Err() before doing expensive work.

Here is a handler that follows production conventions: it accepts a context, validates credentials against a stored hash, and returns structured JSON.

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"golang.org/x/crypto/bcrypt"
)

// validateUser checks credentials against a precomputed bcrypt hash
func validateUser(ctx context.Context, user, pass string) bool {
	// Return early if the context was cancelled or timed out
	if err := ctx.Err(); err != nil {
		return false
	}

	// Compare the incoming password against the stored hash
	err := bcrypt.CompareHashAndPassword([]byte("$2a$10$..."), []byte(pass))
	return err == nil && user == "admin"
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
	// Extract credentials from the request header
	user, pass, ok := r.BasicAuth()

	// Reject missing or invalid credentials immediately
	if !ok || !validateUser(r.Context(), user, pass) {
		w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
		http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
		return
	}

	// Return success payload with proper content type
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

The validateUser function separates validation logic from HTTP plumbing. This makes it testable without spinning up a server. The receiver name convention does not apply here since it is a standalone function, but if you wrap this in a struct, you would use a short name like (s *Server) ProtectedHandler(...). The underscore _ is not used here because we need both the username and password. If you only cared about the username, you would write user, _, ok := r.BasicAuth() to explicitly discard the password. Discarding values intentionally tells future maintainers that the omission was deliberate, not a mistake. Run gofmt on the file. The community accepts the boilerplate because it makes the unhappy path visible. Separate validation from HTTP plumbing. Test the logic, not the server.

Where things go wrong

Basic Auth fails in predictable ways. The most common mistake is using it over HTTP. The Base64 encoding is reversible in a fraction of a second. Anyone on the network can decode the header and read the password. Always serve Basic Auth endpoints over HTTPS. The Go net/http package does not enforce this. You have to configure your reverse proxy or load balancer to terminate TLS.

Another frequent issue is confusing 401 Unauthorized with 403 Forbidden. A 401 means the client did not provide valid credentials. A 403 means the client provided credentials but lacks permission to access the resource. If you return 403 when the password is wrong, browsers will not show the login dialog. They will just display an error page. Stick to 401 for authentication failures. Use 403 only when the user is authenticated but trying to access an admin route they are not allowed to see.

The compiler will complain with cannot use string as []byte in argument if you pass a raw string to bcrypt.CompareHashAndPassword. The bcrypt package expects byte slices. Convert strings with []byte(str). If you forget to import a package, you get undefined: pkg. If you import a package and never use it, the compiler rejects the file with imported and not used. Go enforces clean imports strictly. Remove unused imports before running the program.

Goroutine leaks are not a direct risk with Basic Auth, but they appear when you spawn background tasks inside handlers. If a handler starts a goroutine to process a request and that goroutine waits on a channel that never closes, the goroutine stays in memory. Always pass a context to background goroutines and respect cancellation. The worst goroutine bug is the one that never logs. 401 means missing credentials. 403 means denied access. Pick the right code.

When to reach for Basic Auth

Use Basic Auth when you are building an internal tool that only runs behind a corporate firewall. Use Basic Auth when you need to authenticate a legacy API that does not support OAuth or JWT. Use a bearer token when you are building a public facing mobile or web application. Use session cookies when you need to maintain state across multiple requests without sending credentials on every call. Use mutual TLS when the client and server are both infrastructure components that require cryptographic identity verification. Use plain sequential code when you do not need authentication: the simplest thing that works is usually the right thing.

Where to go next