Authentication and Authorization Patterns
July 30, 2026
Authentication and Authorization Patterns
Authentication answers who are you? Authorization answers what are you allowed to do? Mixing them produces systems that are hard to test and easy to bypass.
Why / Overview
Common failures:
- JWT parsed but signature not verified
- Authn on API gateway, authz forgotten on internal admin routes
- Role checks as string equality scattered in handlers
- IDOR: authorized as user A, still can fetch user B’s resource by id
request -> authenticate -> Principal
-> authorize(Principal, action, resource) -> allow/deny
Authentication Patterns
1. Session cookies (first-party browsers)
// After credential check:
sid := newSessionID()
store.Save(sid, userID, time.Now().Add(24*time.Hour))
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sid,
Path: "/",
HttpOnly: true,
Secure: true, // HTTPS only
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})Use CSRF protection for cookie-based mutating requests.
2. Bearer tokens (APIs)
func bearer(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
return "", false
}
return strings.TrimPrefix(h, "Bearer "), true
}Validate JWT (or opaque token introspection) with:
- Signature / HMAC key or JWKS
exp/nbfississuer allowlistaudaudience allowlist- algorithm allowlist (reject
none)
3. mTLS identity
Peer certificate URI SAN / SPIFFE ID becomes the principal for service-to-service calls (previous chapter). Still run authz: not every client cert may call every route.
4. API keys
Fine for simple server-to-server; store hashes of keys, not raw keys; rate-limit; rotate.
Principal Model
type Principal struct {
Subject string // user id or spiffe://...
Type string // user|service
Roles []string // optional coarse
Scopes []string // oauth-like
Tenant string // multi-tenant systems
}Attach to context:
Authorization Patterns
RBAC (role-based)
Resource ownership (critical for IDOR)
func canReadOrder(p Principal, o Order) bool {
if allowed(p, "order:read:any") {
return true
}
return allowed(p, "order:read:own") && o.UserID == p.Subject
}Always load the resource and check ownership server-side. Never trust client-provided user_id alone.
Policy as pure function
type Request struct {
Principal Principal
Action string
Resource Resource
}
func Authorize(req Request) error {
if !policy(req) {
return ErrDeny
}
return nil
}Pure functions are unit-testable with tables of allow/deny cases.
Middleware composition
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, err := authenticate(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
})
}
func requirePerm(perm string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := PrincipalFrom(r.Context())
if !ok || !allowed(p, perm) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}Status codes: 401 unauthenticated, 403 authenticated but not allowed. Do not leak whether a resource exists if that is sensitive (sometimes always 404).
Deny by Default
// Route registration: only explicitly wrapped routes are reachable.
mux.Handle("GET /admin/metrics", requireAuth(requirePerm("admin:metrics", adminHandler)))Avoid a global “if admin skip checks” flag that can be left on.
Multi-Tenant Awareness
Every query should be scoped:
Authorization + data filtering work together.
Audit Logging
Log authz denials and sensitive allows:
slog.Warn("authz_deny",
"sub", p.Subject,
"action", action,
"resource", resourceID,
"request_id", RequestID(ctx),
)Never log raw passwords or tokens.
Testing Matrix
| Case | Expect |
|---|---|
| No credentials | 401 |
| Bad signature / expired | 401 |
| Valid user, wrong role | 403 |
| Valid user, not owner | 403 |
| Valid owner | 200 |
| Admin override | 200 if policy says so |
Table-driven tests for Authorize catch regressions better than handler snapshots alone.
Production Checklist
- Authn and authz modules separated
- Token validation includes iss/aud/exp/alg
- Cookies: HttpOnly, Secure, SameSite set intentionally
- CSRF strategy for cookie sessions
- Resource-level checks (anti-IDOR)
- Default deny routing
- Tenant scoping in data access
- Audit logs for deny and admin actions
- Load tests do not bypass auth in prod configs
- Service-to-service identity (mTLS or signed tokens)
Common Pitfalls
- Parse-only JWT without verify.
- Trust gateway headers (
X-User-Id) without authenticating the gateway hop. - Frontend-only authorization.
- String role checks duplicated and drifting.
- 403 vs 401 confusion breaking client refresh flows.
- Overbroad
adminrole granted to services. - Debug auth bypass left enabled.
Exercises
- Implement session cookie login/logout with secure flags; attempt theft via XSS hypothetical — why HttpOnly helps.
- Validate a JWT with wrong
aud; prove rejection. - Build
Authorizepure function with 10 table cases including IDOR. - Create two middleware layers; write tests with
httptest. - Add tenant_id to queries; attempt cross-tenant read; deny.
- Log authz deny with request id; redact token fields.
- Convert scattered role checks into a permission map; delete duplicates.
- Simulate expired token; ensure 401 not 500.
- Service identity: map mTLS SPIFFE ID to Principal; authorize routes.
- Threat-model one endpoint: list authn/authz failures and tests for each.
More examples
Bearer authn middleware
Save as main.go:
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type ctxKey string
const userKey ctxKey = "user"
func bearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(h, "Bearer ")
if token != "good-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, "alice")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func main() {
h := bearerAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, r.Context().Value(userKey))
}))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
fmt.Println("missing:", rr.Code)
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer good-token")
h.ServeHTTP(rr, req)
fmt.Println("ok:", rr.Code, strings.TrimSpace(rr.Body.String()))
}Expected output:
missing: 401
ok: 200 alice
Role-based authz gate
Save as main.go:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
type Principal struct {
Name string
Roles map[string]bool
}
func requireRole(role string, p Principal, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !p.Roles[role] {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
admin := Principal{Name: "a", Roles: map[string]bool{"admin": true}}
user := Principal{Name: "u", Roles: map[string]bool{"user": true}}
core := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "secret")
})
rr := httptest.NewRecorder()
requireRole("admin", user, core).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
fmt.Println("user:", rr.Code)
rr = httptest.NewRecorder()
requireRole("admin", admin, core).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
fmt.Println("admin:", rr.Code)
}Expected output:
user: 403
admin: 200
Runnable example
Bearer token authn middleware + pure Authorize function with table-driven cases (including IDOR-style deny)—httptest only.
Save as main.go:
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Principal struct {
ID string
Role string
}
type ctxKey string
const principalKey ctxKey = "principal"
func authenticate(token string) (Principal, bool) {
// Demo token map; production: verify JWT/session server-side.
db := map[string]Principal{
"tok-alice": {ID: "u1", Role: "user"},
"tok-admin": {ID: "u0", Role: "admin"},
}
p, ok := db[token]
return p, ok
}
func Authorize(p Principal, action, resourceOwner string) bool {
if p.Role == "admin" {
return true
}
if action == "read" || action == "write" {
return p.ID == resourceOwner // prevent IDOR
}
return false
}
func withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
p, ok := authenticate(strings.TrimPrefix(h, "Bearer "))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), principalKey, p)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func main() {
mux := http.NewServeMux()
mux.Handle("GET /items/{owner}", withAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.Context().Value(principalKey).(Principal)
owner := r.PathValue("owner")
if !Authorize(p, "read", owner) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
fmt.Fprintf(w, "ok user=%s owner=%s\n", p.ID, owner)
})))
call := func(token, path string) int {
req := httptest.NewRequest(http.MethodGet, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
return rr.Code
}
fmt.Println("no token:", call("", "/items/u1"))
fmt.Println("alice own:", call("tok-alice", "/items/u1"))
fmt.Println("alice idor:", call("tok-alice", "/items/u9"))
fmt.Println("admin idor:", call("tok-admin", "/items/u9"))
fmt.Println("bad token:", call("nope", "/items/u1"))
}Expected output:
no token: 401
alice own: 200
alice idor: 403
admin idor: 200
bad token: 401
What to notice
- 401 = not authenticated; 403 = authenticated but not allowed—clients refresh on 401, not 403.
- Authorization is a pure function—easy to table-test; do not scatter role checks only in the UI.
- Resource owner checks stop IDOR when IDs appear in URLs.
Try next
- Add
tenant_idto Principal and deny cross-tenant reads. - Validate JWT
aud/iss/expwithcrypto+ careful parsing libraries (or session cookies).
Further Reading
- OWASP Authentication and Access Control cheatsheets
- Previous: TLS/mTLS as transport authn
- Next: Secrets Management and Rotation