net/go.book
All Parts Marketing

Authentication and Authorization: Security Theory and Go Implementation

"A bouncer at a club does two separate jobs: check your ID to confirm you are who you say you are (authentication), and check the guest list to decide whether that person is allowed into the VIP room (authorization). Mixing up the two jobs is how security bugs happen — verifying who somebody is tells you nothing about what they're allowed to do."


Authentication vs. Authorization

Every HTTP handler you've written since the HTTP chapter has implicitly trusted every request. Once an application matters — user accounts, private data, paid features — it needs to answer two distinct questions on every request:

  • Authentication (authn): Who is making this request? Proving identity — a password, a token, a client certificate.
  • Authorization (authz): Is this identity allowed to do this specific thing? Checking permissions, roles, or ownership.

A request can be authenticated (we know it's Alice) and still be unauthorized (Alice isn't an admin). Keeping these as two separate checks, in two separate places in your code, makes both easier to reason about and to test.

Common Authentication Schemes

  • Basic Auth: Username and password sent (base64-encoded, not encrypted) on every request. Simple, but only safe over TLS.
  • API keys: A long-lived secret token identifying a caller, usually sent as a header.
  • Bearer tokens: A token — often a JWT or an opaque session token — sent in an Authorization: Bearer <token> header, typically issued after a login step.
  • mTLS (mutual TLS): Both client and server present certificates during the TLS handshake, authenticating each other at the transport layer, before any application data is exchanged. Covered in depth in the next chapter.

Authentication answers "who are you"; authorization answers "what can you do"; a token is a portable, tamper-evident answer to the first question that a server can check without re-asking it.


Go Implementation: HTTP Basic Auth

net/http's Request type parses the Authorization header for you:

func basicAuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, pass, ok := r.BasicAuth()
		if !ok || !validCredentials(user, pass) {
			w.Header().Set(
				"WWW-Authenticate", `Basic realm="restricted"`,
			)
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

This is the same middleware-wrapping pattern used for logging and other cross-cutting concerns: a function that takes an http.Handler and returns a new one, letting you layer authentication in front of any handler without touching its logic.

Never compare secrets with ==
Comparing passwords or tokens with == leaks timing information an attacker can use to guess the value byte by byte. Use crypto/subtle.ConstantTimeCompare for any secret comparison.

import "crypto/subtle"

func validCredentials(user, pass string) bool {
	expectedUser := []byte("admin")
	expectedPass := []byte(storedPasswordHash) // never store plaintext
	return subtle.ConstantTimeCompare([]byte(user), expectedUser) == 1 &&
		subtle.ConstantTimeCompare([]byte(pass), expectedPass) == 1
}

Basic Auth has no real logout
Browsers cache Basic Auth credentials for the session and resend them automatically to the same realm, so there is no clean way to "log out" short of closing the browser or changing the password — there's no server-side session to invalidate. Combined with credentials traveling on every request (base64 is encoding, not encryption), treat Basic Auth as TLS-only and reserve it for machine-to-machine calls, not user-facing login flows.


Hashing Passwords Correctly

Never store passwords in plaintext, and never hash them with a fast general-purpose hash like SHA-256 alone — it's designed to be fast, which is exactly what makes brute-forcing a stolen password database cheap. Passwords need a slow, salted, adaptive hash. The standard library doesn't ship one, so the Go ecosystem's accepted answer is golang.org/x/crypto/bcrypt, maintained by the Go team as an extension of the standard library:

import "golang.org/x/crypto/bcrypt"

func hashPassword(plain string) (string, error) {
	hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
	return string(hash), err
}

func checkPassword(hash, plain string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}

bcrypt embeds a random salt in its output automatically and deliberately takes milliseconds to compute — that cost is the whole point.

bcrypt silently truncates past 72 bytes
bcrypt.GenerateFromPassword only looks at the first 72 bytes of its input; anything beyond that is silently ignored rather than rejected. This rarely matters for typical passwords, but a "paste your whole passphrase" feature can end up validating only a prefix of what the user typed, weakening the effective password with no error returned. If you need to support arbitrarily long input safely, hash it with SHA-256 first and feed that fixed-length digest into bcrypt, or switch to golang.org/x/crypto/argon2, which has no such limit.


Token-Based Authentication

For APIs, a common pattern issues a signed token after login, which the client then sends on every subsequent request instead of resending credentials. You don't need a third-party JWT library to understand or build the core mechanism — a signed token is just a payload plus an HMAC signature proving it wasn't tampered with:

Never hardcode the signing secret
secretKey below is a literal string for the sake of a short example — in real code that is a critical security bug. Anyone with read access to the source (or a decompiled binary) can forge valid tokens for any user. Load signing secrets from an environment variable or a secrets manager, generate them with crypto/rand at a length appropriate for the hash (at least 32 random bytes for HMAC-SHA256), and rotate them periodically, which means the verifier must accept both the current and immediately previous key during a rotation window.

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strings"
	"sync"
	"time"
)

var secretKey = []byte("server-only-secret-key") // load from env/secrets

func issueToken(userID string) string {
	payload := fmt.Sprintf(
		"%s|%d", userID, time.Now().Add(1*time.Hour).Unix())
	mac := hmac.New(sha256.New, secretKey)
	mac.Write([]byte(payload))
	sig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
	return base64.URLEncoding.EncodeToString([]byte(payload)) + "." + sig
}

// revocationList lets a still-unexpired token be invalidated immediately
// — on logout, or when a token is suspected compromised — something a
// self-contained signature check alone can never do: a valid signature
// only proves the token wasn't tampered with, not that it's still wanted.
type revocationList struct {
	mu      sync.Mutex
	revoked map[string]time.Time // signature -> time of revocation
}

func newRevocationList() *revocationList {
	return &revocationList{revoked: make(map[string]time.Time)}
}

func (r *revocationList) revoke(sig string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.revoked[sig] = time.Now()
}

func (r *revocationList) isRevoked(sig string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	_, revoked := r.revoked[sig]
	return revoked
}

func verifyToken(
	token string, revoked *revocationList,
) (userID string, ok bool) {
	parts := strings.SplitN(token, ".", 2)
	if len(parts) != 2 {
		return "", false
	}
	payloadBytes, err := base64.URLEncoding.DecodeString(parts[0])
	if err != nil {
		return "", false
	}

	mac := hmac.New(sha256.New, secretKey)
	mac.Write(payloadBytes)
	expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) {
		// signature mismatch — token was tampered with or forged
		return "", false
	}
	if revoked.isRevoked(parts[1]) {
		return "", false // valid signature, but explicitly logged out
	}

	fields := strings.SplitN(string(payloadBytes), "|", 2)
	if len(fields) != 2 {
		return "", false
	}
	var expiry int64
	if _, err := fmt.Sscanf(fields[1], "%d", &expiry); err != nil {
		return "", false
	}
	if time.Now().Unix() > expiry {
		return "", false // expired
	}
	return fields[0], true
}

This is structurally the same idea a JWT implements: a payload plus a signature the server can verify without a database round trip, since the signature itself proves the payload wasn't altered. hmac.Equal — not == — is used for the same constant-time reason as the password comparison above. Note also that the original version of verifyToken ignored the error from fmt.Sscanf and never checked len(fields), meaning a malformed payload could panic on fields[1] or silently parse a bogus expiry as 0 — both are fixed above.

Allow a little clock skew, but not none
Comparing time.Now().Unix() > expiry with zero tolerance means a token can be rejected early just because the verifying server's clock is slightly ahead of the issuer's — a real occurrence when hosts aren't perfectly synchronized. Production verifiers typically allow a small leeway (a few seconds). The opposite mistake, too much leeway, extends how long an expired or revoked token stays usable, so keep it small.

A signature proves a token wasn't altered; it says nothing about whether the token is still wanted — that's what revocation and expiry are for.


Go Implementation: Authorization Middleware

Once a request is authenticated, authorization is a separate check — typically comparing the authenticated identity's role against what the requested action requires:

type contextKey string

const userIDKey contextKey = "userID"

func requireRole(
	role string, revoked *revocationList, next http.Handler,
) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
		userID, ok := verifyToken(token, revoked)
		if !ok {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		if !userHasRole(userID, role) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		ctx := context.WithValue(r.Context(), userIDKey, userID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Try It Yourself: extend the middleware above into a complete auth server — a /login handler that checks a password and issues a JWT, this requireAuth middleware guarding a protected route, and a requireRole check on top of it for an admin-only endpoint. There's no bundled exercise file for this chapter yet, so build it from the pieces above.

Attaching the authenticated user ID to the request's context.Context — the same context type used for cancellation earlier in the book — lets downstream handlers retrieve "who is making this request" without re-parsing the token, using r.Context().Value(userIDKey).

Note the distinct status codes: 401 Unauthorized means "I don't know who you are," while 403 Forbidden means "I know who you are, and the answer is no" — a direct reflection of the authn/authz split at the HTTP level.

Missing middleware is a silent bypass
requireRole only protects a route if every handler for that route is wrapped with it. A new endpoint that forgets to wrap its handler is reachable by anyone, with no error or log line to flag the mistake — the failure is silent by omission, not by a failed check. Prefer applying authorization at the router level (whole route groups) over remembering per-handler, and consider a default-deny catch-all route so an unmatched path fails closed rather than falling through unauthenticated.


Try It Yourself: Logout and Role Hierarchies

  • Wire up logout. Add a handler that extracts the token's signature and calls revoked.revoke(sig), then confirm a later request with that token is rejected even though it hasn't expired.
  • Write a table-driven test. Using net/http/httptest, test requireRole against four cases: no token, expired token, wrong role, success — the authn and authz failures should return different status codes.
  • Add a role hierarchy. Replace the single-string check in userHasRole with an ordered list ("viewer" < "editor" < "admin") so requireRole("editor", ...) also accepts an "admin" caller.
  • Simulate clock skew. Back-date a test's notion of "now" a few seconds past expiry and confirm your leeway (deep dive above) behaves as expected at the boundary.

Frequently Asked Questions

If a token has a valid signature, why does the code still check a revocation list? Because a signature only proves the payload wasn't tampered with — it says nothing about whether the token is still wanted, which is exactly what the closing axiom of the token section calls out. That's why verifyToken checks revoked.isRevoked(parts[1]) even after the HMAC check passes: a user who logs out should stop being trusted immediately, not merely once their still-unexpired token finally times out on its own.

Why does the chapter bother hand-rolling an HMAC token instead of just reaching for a JWT library from the start? So the mechanism is visible: a signed token really is just a payload plus a signature the server can re-derive and compare, with no database round trip needed. Once that's clear, the JWT deep dive shows how a real JWT is the same three-part, base64url, dot-separated shape — just with the payload formalized as JSON claims like sub and exp, and a header naming the signing algorithm.

Should I ever compare a submitted password or token with ==? No — == on secrets leaks timing information an attacker can exploit to guess a value byte by byte, since a mismatch on the first byte returns fractionally faster than a mismatch on the last. This chapter uses crypto/subtle.ConstantTimeCompare for the Basic Auth credentials and hmac.Equal for the token signature specifically to close off that side channel.

Why bcrypt instead of just hashing passwords with SHA-256? Because SHA-256 is designed to be fast, and fast is exactly the property that makes brute-forcing a stolen password database cheap. bcrypt.GenerateFromPassword is deliberately slow and adaptive, embedding its own salt, and remains battle-tested with no known practical break — though the chapter's deep dive notes argon2id as OWASP's current pick for new systems, since it can also be tuned for memory cost.

What's the actual difference between a 401 and a 403 response, and why does it matter? 401 Unauthorized means the server doesn't know who is making the request — authentication failed or was never attempted. 403 Forbidden means the server knows exactly who you are and has decided the answer is still no — authorization failed. Returning the wrong one blurs the authn/authz split the whole chapter is built around, which is why requireRole in this chapter deliberately returns different status codes for those two failure paths.


Key Takeaways

  • Authentication proves identity; authorization decides what that identity may do. Keep the checks separate.
  • Use crypto/subtle.ConstantTimeCompare or hmac.Equal for any secret comparison — never ==.
  • Hash passwords with an adaptive, salted algorithm like bcrypt — never a fast general-purpose hash.
  • Signed tokens (HMAC-based, or a full JWT library) let a server verify a caller's identity without a database lookup on every request.
  • A signature alone can't express "logged out" — pair short-lived tokens with a revocation check for anything that needs immediate invalidation.
  • 401 vs. 403 is not a stylistic choice — it communicates which half of the authn/authz split failed.