How to Implement Basic Auth in Go

Web
Implement Basic Auth in Go using the net/http package's BasicAuth helper to validate credentials and return 401 errors for unauthorized access.

The quick gate for internal tools

You are building a small internal dashboard, a webhook receiver, or a debug endpoint that needs a credential check to keep automated bots out. You do not want to spin up a full OAuth provider, manage a database of users, or handle session cookies. You just need a username and password check that works immediately. Basic Auth is the standard HTTP mechanism for this scenario, and Go's net/http package provides a built-in helper to decode and validate the credentials.

Basic Auth is simple, widely supported, and requires zero external dependencies. It works by sending the username and password in the request header on every call. The trade-off is that credentials travel with every request, and the encoding is trivial to reverse. Basic Auth is the right tool when you need a lightweight gate and you can enforce HTTPS to protect the transport.

How Basic Auth works under the hood

Basic Auth relies on the Authorization header. The client sends credentials in the format Basic <credentials>, where <credentials> is the Base64 encoding of username:password. The server receives the header, strips the Basic prefix, decodes the Base64 string, and splits the result on the first colon to extract the username and password.

Base64 is encoding, not encryption. It converts binary data into a safe ASCII representation so it can travel in HTTP headers. It does not hide the password. Anyone who intercepts the header can decode it instantly using any standard tool. Basic Auth provides no confidentiality on its own. You must always use HTTPS with Basic Auth. The TLS layer encrypts the traffic; Basic Auth just structures the credentials.

Go's *http.Request exposes the BasicAuth() method to handle this parsing. The method returns the username, password, and a boolean indicating whether the header was present and well-formed. If the header is missing or malformed, the boolean is false, and the username and password may be empty or partial. The method also handles edge cases like passwords containing colons. The split happens only on the first colon, so a password like p@ss:word is parsed correctly as user and p@ss:word.

Base64 is not a secret. HTTPS is the lock.

Minimal handler with browser support

Here is the simplest way to protect a route. The handler calls r.BasicAuth(), checks the result against expected values, and returns a 401 status if the check fails.

package main

import (
	"net/http"
)

func main() {
	// Hardcoded for demonstration; use environment variables in production.
	const validUser = "admin"
	const validPass = "secret"

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// r.BasicAuth decodes the Authorization header and splits the credentials.
		// It returns false if the header is missing or malformed.
		user, pass, ok := r.BasicAuth()
		if !ok || user != validUser || pass != validPass {
			// Set the header to trigger the browser's login dialog.
			// The realm string is arbitrary but helps browsers cache credentials.
			w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}

		w.Write([]byte("Access granted"))
	})

	// ListenAndServe blocks until the server exits.
	http.ListenAndServe(":8080", nil)
}

When a client requests the URL without credentials, the handler returns 401. Browsers look for the WWW-Authenticate header in the 401 response. If present, they display a login dialog. If missing, they just show the error text. Go's http.Error writes the status code and body but does not add the WWW-Authenticate header automatically. You must set the header explicitly to get the browser popup. The realm parameter is a string that identifies the protected area. Browsers may cache credentials per realm. If you have multiple protected sections, use different realm names to prevent credential leakage between them.

Trust the standard library. r.BasicAuth handles the decoding and splitting correctly.

Middleware for reusable protection

Hardcoding the check in every handler creates duplication. The idiomatic Go approach is to wrap the logic in a middleware function. Middleware takes an http.Handler, returns an http.Handler, and intercepts the request before it reaches the inner handler. This pattern keeps the authentication logic centralized and composable.

// BasicAuthMiddleware wraps a handler with credential validation.
func BasicAuthMiddleware(user, pass string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Decode credentials from the request header.
			reqUser, reqPass, ok := r.BasicAuth()
			if !ok || reqUser != user || reqPass != pass {
				// Challenge the client with the realm name.
				w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}

			// Pass control to the next handler in the chain.
			next.ServeHTTP(w, r)
		})
	}
}

The middleware returns a closure that captures the expected credentials. When the server receives a request, the middleware decodes the header and validates the values. If validation fails, it writes the 401 response and returns early, preventing the inner handler from running. If validation succeeds, it calls next.ServeHTTP to continue the chain. This structure allows you to stack multiple middlewares, such as logging or CORS, around the same handler.

Middleware chains flow top-to-bottom. Order determines behavior.

Security details and conventions

Credentials should never be hardcoded in source code. Committing passwords to version control exposes them to anyone with repository access. Use environment variables to inject secrets at runtime. The os.Getenv function retrieves values from the environment. Check for empty values and fail fast if required secrets are missing.

package main

import (
	"net/http"
	"os"
)

func main() {
	// Read credentials from environment to avoid hardcoding secrets.
	user := os.Getenv("BASIC_AUTH_USER")
	pass := os.Getenv("BASIC_AUTH_PASS")

	if user == "" || pass == "" {
		panic("BASIC_AUTH_USER and BASIC_AUTH_PASS must be set")
	}

	// Chain the middleware around the root handler.
	handler := BasicAuthMiddleware(user, pass)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Protected resource"))
	}))

	http.ListenAndServe(":8080", handler)
}

String comparison in Go is not constant time. The == operator returns early when it finds a mismatch, which can leak information about the correct password length or prefix through timing analysis. For high-security applications, use subtle.ConstantTimeCompare to compare byte slices in constant time. Basic Auth is rarely the target of sophisticated timing attacks because the credentials are sent in the clear over the network, but the convention exists for sensitive comparisons.

import "crypto/subtle"

// Compare passwords in constant time to mitigate timing attacks.
if subtle.ConstantTimeCompare([]byte(reqPass), []byte(validPass)) != 1 {
    // Mismatch
}

The Go community convention for error handling is to check errors immediately and return. The pattern if err != nil { return err } is verbose by design. It makes the unhappy path visible. In middleware, returning early on authentication failure follows this pattern. The response is written, and the function returns, ensuring no further processing occurs.

Environment variables keep secrets out of version control.

Pitfalls and compiler errors

Forgetting to import a package triggers undefined: http from the compiler. The compiler also rejects unused imports with imported and not used. Go requires every import to be used, which keeps code clean. If you import os but do not call os.Getenv, the build fails.

The r.BasicAuth() method returns three values. Ignoring the boolean ok is a common mistake. If the header is malformed, ok is false, and the username and password may be empty strings. Checking only user != validUser can pass when the header is missing, because an empty string might equal a hardcoded empty username. Always check ok first.

// BAD: Skips the ok check.
user, pass, _ := r.BasicAuth()
if user != validUser { ... }

// GOOD: Checks ok first.
user, pass, ok := r.BasicAuth()
if !ok || user != validUser { ... }

Basic Auth sends credentials on every request. This increases bandwidth slightly and exposes the password to any middleware or logging layer that inspects headers. Ensure your logging middleware redacts the Authorization header. Printing request headers to logs can accidentally leak credentials.

The WWW-Authenticate header must be set before calling http.Error or writing the body. HTTP headers are sent before the body. Once the body starts writing, headers are committed. Setting headers after w.Write has no effect.

Headers are sent before the body. Set them early.

Decision matrix

Use Basic Auth when you need a simple credential check for internal tools, scripts, or debug endpoints and you can enforce HTTPS. Use a session-based approach when you need to remember the user across requests without sending credentials every time, such as for web applications with interactive logins. Use OAuth or JWT when you are building a public API consumed by third-party applications that require delegated access or stateless tokens. Use API keys when the client is a machine or service that does not support interactive login dialogs and you want to track usage per client. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Where to go next