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
Authorizationheader 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
}
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, andSameSitecookie 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.