net/go.book
All Parts Marketing

API Security: Tokens, Auth, and Best Practices

An API without authentication is an API anyone can call — including people you didn't intend to let in. This chapter covers the common authentication schemes you'll actually implement, a working JWT setup using golang-jwt/jwt/v5, password hashing with bcrypt, and a checklist of practices that matter more than which specific scheme you pick. Part 2, Chapter 18 introduced the authentication-versus-authorization split and hand-rolled an HMAC token from first principles; here we focus on the API-layer concerns and battle-tested libraries.


The Authentication Landscape

  • Basic Auth: username and password sent base64-encoded in the Authorization header on every request. Simple, but only safe over HTTPS, and it doesn't support scopes, expiry, or revocation without extra machinery.
  • API keys: a static, opaque string identifying a client (Authorization: Bearer sk_live_... or a custom header). Common for server-to-server and third-party integrations; easy to revoke by looking the key up in a database, but the key itself never expires on its own.
  • JWT (JSON Web Tokens): a signed, self-contained token carrying claims (user ID, roles, expiry) that the server can verify without a database lookup. Widely used for session tokens issued after login.
  • OAuth2 / OpenID Connect: a full delegation protocol for letting a third party (Google, GitHub, your own auth service) authenticate a user on your API's behalf. Covered in depth in a later chapter — this chapter focuses on the token mechanics you'd use once a session is established.

Hashing Passwords with bcrypt

Never store a plaintext password, and never roll your own hashing scheme. golang.org/x/crypto/bcrypt is the standard choice in Go:

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(plain, hash string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}

bcrypt.DefaultCost (currently 10) balances hashing time against brute-force resistance; raising the cost factor makes each guess more expensive for an attacker at the cost of slightly slower logins for legitimate users.


Issuing a JWT

go get github.com/golang-jwt/jwt/v5
import (
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type Claims struct {
	UserID string `json:"user_id"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

var signingKey = []byte("replace-with-a-real-secret-from-config")

func issueToken(userID, role string) (string, error) {
	claims := Claims{
		UserID: userID,
		Role:   role,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(
				time.Now().Add(15 * time.Minute),
			),
			IssuedAt: jwt.NewNumericDate(time.Now()),
			Issuer:   "api.example.com",
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(signingKey)
}

Embedding your own fields (UserID, Role) alongside jwt.RegisteredClaims gives you the standard claims (exp, iat, iss) plus whatever custom data your API needs, in one struct.


Verifying a JWT

func parseToken(tokenString string) (*Claims, error) {
	claims := &Claims{}
	keyFunc := func(t *jwt.Token) (any, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf(
				"unexpected signing method: %v", t.Header["alg"])
		}
		return signingKey, nil
	}
	token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)
	if err != nil || !token.Valid {
		return nil, fmt.Errorf("invalid token: %w", err)
	}
	return claims, nil
}

Always check the signing method
jwt.ParseWithClaims calls back into your code specifically so you can verify t.Method before trusting the key. Without that check, a well-known JWT vulnerability lets an attacker submit a token with alg: none or swap the algorithm to trick a naive verifier into skipping signature validation entirely. Always assert the expected algorithm inside the key function, as shown above.


Middleware to Require a Valid Token

type contextKey string

const userContextKey contextKey = "user"

func requireAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		header := r.Header.Get("Authorization")
		token, found := strings.CutPrefix(header, "Bearer ")
		if !found || token == "" {
			http.Error(w,
				"missing bearer token", http.StatusUnauthorized)
			return
		}

		claims, err := parseToken(token)
		if err != nil {
			http.Error(w,
				"invalid or expired token", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userContextKey, claims)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Storing the parsed claims on the request context lets downstream handlers read r.Context().Value(userContextKey) without re-parsing the token.


Real-World Example: Login and Authenticated Request

Login request:

POST /login HTTP/1.1
Content-Type: application/json

{"email": "ada@example.com", "password": "correct-horse-battery-staple"}

Login response:

HTTP/1.1 200 OK
Content-Type: application/json

{"access_token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 900}

Authenticated request using that token:

GET /me HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Access Tokens and Refresh Tokens

Short-lived access tokens (minutes, not days) limit how long a stolen token stays useful, but forcing a full re-login every 15 minutes is a poor user experience. The standard fix is a pair of tokens: a short-lived access token used on every request, and a long-lived refresh token, stored more carefully (often as an HTTP-only cookie), used only to obtain a new access token from a dedicated /token/refresh endpoint. Refresh tokens should be revocable server-side — store them (or their hash) in a database so a logout or compromise can invalidate them immediately, something a self-contained JWT alone cannot do.


A Security Checklist

  • HTTPS everywhere: tokens sent over plain HTTP are trivially intercepted; never make an exception for internal traffic without a strong reason.
  • Short-lived access tokens paired with revocable refresh tokens.
  • Store secrets in configuration, never in source control — load the JWT signing key from an environment variable or secrets manager.
  • Rate-limit authentication endpoints specifically (covered next chapter) — login and token-refresh endpoints are the highest-value brute-force targets.
  • Use HttpOnly, Secure, and SameSite cookie flags if you store tokens in cookies, to reduce exposure to XSS and CSRF.
  • Principle of least privilege: encode only the role or scope a client needs, and check it on every request — don't assume "has a valid token" means "authorized for this action."

A valid token answers "who is this?" — authorization, checked separately on every request, answers "are they allowed to do this?" Never let the first question stand in for the second.


Frequently Asked Questions

If a JWT is self-contained and doesn't need a database lookup, why bother with refresh tokens and revocation at all? That self-contained property is exactly the problem: once you've signed a token, there's no way to reach out and un-sign it before it expires. Keeping the access token short-lived shrinks the window a stolen token is dangerous, while the refresh token — which does live in a database — gives you the revocation switch a bare JWT can never have on its own.

Why does parseToken bother checking t.Method before returning the signing key — isn't the signature check enough? No, and this is one of the more famous JWT footguns. A malicious client can hand-craft a token that claims alg: none or swaps to a different algorithm family, and a verifier that trusts the token's own header to pick the algorithm can be tricked into skipping validation entirely. Asserting *jwt.SigningMethodHMAC inside the key function, as shown in this chapter, forces the server — not the token — to decide which algorithm is acceptable.

Is bcrypt.DefaultCost good enough for production, or should I raise it? DefaultCost (10) is a reasonable floor, not a ceiling — it's a deliberate balance point chosen years ago, and hardware has only gotten faster since. Bumping the cost factor higher makes brute-forcing a stolen hash more expensive at the price of slightly slower logins, so it's worth benchmarking on your actual production hardware rather than assuming the default is future-proof forever.

Why store the JWT signing key in an environment variable instead of just hardcoding it like the example does? The example's signingKey = []byte("replace-with-a-real-secret-from-config") is a placeholder, not a suggestion — anything committed to source control is permanently compromised the moment the repository is, even if it's later rotated. Loading it from an environment variable or a secrets manager means the key can be rotated without a code change and never appears in a diff.

My API already checks that a token is valid — do I still need separate authorization checks? Yes, and this is the exact trap the chapter's closing axiom warns about. A valid token only proves who the caller is; a logged-in user with a valid token can still try to read someone else's order or call an admin-only endpoint, so every handler needs its own "is this user allowed to do this specific thing" check rather than treating authentication as a stand-in for authorization.

Next, we'll look at defending these same endpoints against abuse at the traffic level: rate limiting, CORS, and the role of an API gateway.