Authentication and Authorization Patterns

Updated

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 / nbf
  • iss issuer allowlist
  • aud audience allowlist
  • algorithm allowlist (reject none)
// Conceptual validation steps — use a well-reviewed library.
tok, err := jwt.Parse(token, keyFunc, jwt.WithValidMethods([]string{"RS256"}))
// check claims.Issuer, claims.Audience, claims.ExpiresAt

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:

type principalKey struct{}

func WithPrincipal(ctx context.Context, p Principal) context.Context {
    return context.WithValue(ctx, principalKey{}, p)
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
    p, ok := ctx.Value(principalKey{}).(Principal)
    return p, ok
}

Authorization Patterns

RBAC (role-based)

var perms = map[string][]string{
    "admin": {"order:read", "order:write", "user:read"},
    "user":  {"order:read", "order:write:own"},
}

func allowed(p Principal, perm string) bool {
    for _, role := range p.Roles {
        for _, x := range perms[role] {
            if x == perm {
                return true
            }
        }
    }
    return false
}

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:

// BAD: SELECT * FROM orders WHERE id=$1
// GOOD: SELECT * FROM orders WHERE id=$1 AND tenant_id=$2

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

  1. Parse-only JWT without verify.
  2. Trust gateway headers (X-User-Id) without authenticating the gateway hop.
  3. Frontend-only authorization.
  4. String role checks duplicated and drifting.
  5. 403 vs 401 confusion breaking client refresh flows.
  6. Overbroad admin role granted to services.
  7. Debug auth bypass left enabled.

Exercises

  1. Implement session cookie login/logout with secure flags; attempt theft via XSS hypothetical — why HttpOnly helps.
  2. Validate a JWT with wrong aud; prove rejection.
  3. Build Authorize pure function with 10 table cases including IDOR.
  4. Create two middleware layers; write tests with httptest.
  5. Add tenant_id to queries; attempt cross-tenant read; deny.
  6. Log authz deny with request id; redact token fields.
  7. Convert scattered role checks into a permission map; delete duplicates.
  8. Simulate expired token; ensure 401 not 500.
  9. Service identity: map mTLS SPIFFE ID to Principal; authorize routes.
  10. Threat-model one endpoint: list authn/authz failures and tests for each.

More examples

Bearer authn middleware

mkdir -p /tmp/go-authn-bearer && cd /tmp/go-authn-bearer
go mod init example.com/authn-bearer

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()))
}
go run .

Expected output:

missing: 401
ok: 200 alice

Role-based authz gate

mkdir -p /tmp/go-authz-rbac && cd /tmp/go-authz-rbac
go mod init example.com/authz-rbac

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)
}
go run .

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.

mkdir -p /tmp/go-authz && cd /tmp/go-authz
go mod init example.com/authz

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"))
}
go run .

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_id to Principal and deny cross-tenant reads.
  • Validate JWT aud/iss/exp with crypto + 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