api-security.md 19 KB

API Security Patterns

Table of Contents


API Key Management

Generation

import "crypto/rand"

func generateAPIKey() (string, error) {
    // 32 bytes = 256 bits of entropy
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    // Prefix for easy identification and revocation
    return "sk_live_" + base64.URLEncoding.EncodeToString(b), nil
}

Storage

NEVER store API keys in plaintext.

Store: hash(api_key) in database
Lookup: hash(incoming_key), compare to stored hashes
Display: show only last 4 chars to user ("sk_live_...a1b2")
import "crypto/sha256"

func hashAPIKey(key string) string {
    h := sha256.Sum256([]byte(key))
    return hex.EncodeToString(h[:])
}

Scoping

{
  "key_id": "key_abc123",
  "name": "Production Read-Only",
  "permissions": ["read:users", "read:orders"],
  "rate_limit": 1000,
  "allowed_ips": ["203.0.113.0/24"],
  "expires_at": "2025-01-15T00:00:00Z",
  "created_at": "2024-01-15T00:00:00Z"
}

Rotation Strategy

  1. Generate new key
  2. Both old and new keys work (grace period: 24-72 hours)
  3. Client updates to new key
  4. Old key is revoked
  5. Log all key usage for audit

JWT (JSON Web Tokens)

Structure

header.payload.signature

# Header
{
  "alg": "RS256",          # Algorithm (RS256, ES256 - avoid HS256 for APIs)
  "typ": "JWT",
  "kid": "key-2024-01"    # Key ID for rotation
}

# Payload (Claims)
{
  "iss": "https://auth.example.com",     # Issuer
  "sub": "user-123",                      # Subject (user ID)
  "aud": "https://api.example.com",       # Audience
  "exp": 1705312200,                      # Expires (15 min from now)
  "iat": 1705311300,                      # Issued at
  "jti": "unique-token-id",               # JWT ID (for revocation)
  "scope": "read:users write:orders",     # Permissions
  "org_id": "org-456"                     # Custom claim
}

Signing and Verification (Go)

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

// Sign (auth service)
func createAccessToken(userID string, scopes []string) (string, error) {
    claims := jwt.MapClaims{
        "sub":   userID,
        "scope": strings.Join(scopes, " "),
        "exp":   time.Now().Add(15 * time.Minute).Unix(),
        "iat":   time.Now().Unix(),
        "iss":   "https://auth.example.com",
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    token.Header["kid"] = currentKeyID

    return token.SignedString(privateKey)
}

// Verify (API service)
func verifyToken(tokenString string) (*jwt.Token, error) {
    return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // Validate algorithm
        if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }

        // Look up public key by kid
        kid, _ := token.Header["kid"].(string)
        pubKey, err := getPublicKey(kid)
        if err != nil {
            return nil, fmt.Errorf("unknown key ID: %s", kid)
        }

        return pubKey, nil
    },
        jwt.WithValidMethods([]string{"RS256"}),
        jwt.WithIssuer("https://auth.example.com"),
        jwt.WithAudience("https://api.example.com"),
    )
}

Refresh Token Flow

1. Login: POST /auth/login
   Response: { access_token (15 min), refresh_token (7 days) }

2. API calls: Authorization: Bearer <access_token>

3. Token expired (401): POST /auth/refresh
   Body: { refresh_token }
   Response: { access_token (new, 15 min), refresh_token (rotated) }

4. Refresh token expired/revoked: redirect to login

Token Storage

Environment Access Token Refresh Token
Browser SPA Memory (JS variable) HttpOnly Secure cookie
Mobile app Secure storage (Keychain/Keystore) Secure storage
Server-to-server Environment variable Environment variable

Never store tokens in:

  • localStorage (XSS vulnerable)
  • sessionStorage (XSS vulnerable)
  • Non-HttpOnly cookies (XSS vulnerable)
  • URL parameters (logged, cached, leaked via Referer)

OAuth2 Flows

Authorization Code + PKCE (SPAs, Mobile)

1. Client generates: code_verifier (random 43-128 chars)
   code_challenge = BASE64URL(SHA256(code_verifier))

2. Redirect to authorization server:
   GET /authorize?
     response_type=code&
     client_id=app-123&
     redirect_uri=https://app.example.com/callback&
     scope=read:profile write:orders&
     state=random-csrf-token&
     code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
     code_challenge_method=S256

3. User authenticates, consents

4. Redirect back with code:
   GET /callback?code=auth-code-xyz&state=random-csrf-token

5. Exchange code for tokens:
   POST /token
   {
     "grant_type": "authorization_code",
     "code": "auth-code-xyz",
     "redirect_uri": "https://app.example.com/callback",
     "client_id": "app-123",
     "code_verifier": "the-original-random-string"
   }

6. Response:
   {
     "access_token": "eyJ...",
     "token_type": "Bearer",
     "expires_in": 900,
     "refresh_token": "rt_...",
     "scope": "read:profile write:orders"
   }

Client Credentials (Server-to-Server)

POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&
client_id=service-abc&
client_secret=secret-xyz&
scope=read:users

Response:
{
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Device Flow (CLI Tools, Smart TVs)

1. Device requests code:
   POST /device/code
   { "client_id": "cli-app", "scope": "read:profile" }

   Response:
   {
     "device_code": "device-code-abc",
     "user_code": "ABCD-1234",
     "verification_uri": "https://auth.example.com/device",
     "expires_in": 600,
     "interval": 5
   }

2. Display to user: "Go to https://auth.example.com/device and enter ABCD-1234"

3. Device polls (every 5 seconds):
   POST /token
   { "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
     "device_code": "device-code-abc", "client_id": "cli-app" }

   While pending: { "error": "authorization_pending" }
   When approved: { "access_token": "eyJ...", ... }

Flow Selection Guide

Scenario Flow
SPA (browser) Authorization Code + PKCE
Mobile app Authorization Code + PKCE
Server-to-server Client Credentials
CLI tool Device Flow
Legacy (avoid) Implicit (deprecated), ROPC (deprecated)

CORS

Configuration

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")

        // Whitelist specific origins (NEVER use * with credentials)
        allowedOrigins := map[string]bool{
            "https://app.example.com":     true,
            "https://staging.example.com": true,
        }

        if allowedOrigins[origin] {
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
            w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID, Idempotency-Key")
            w.Header().Set("Access-Control-Expose-Headers", "X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset")
            w.Header().Set("Access-Control-Max-Age", "86400")  // Cache preflight for 24h
        }

        // Handle preflight
        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

Common CORS Mistakes

Mistake Risk Fix
Access-Control-Allow-Origin: * with credentials Credential theft Whitelist specific origins
Reflecting Origin header without validation Any origin allowed Check against whitelist
Missing Vary: Origin Cache poisoning Add Vary: Origin header
Not handling preflight (OPTIONS) Mutations blocked Return 204 for OPTIONS
Allowing all headers Header injection Whitelist specific headers

Rate Limiting Implementation

Token Bucket (Go + Redis)

import "github.com/redis/go-redis/v9"

type RateLimiter struct {
    redis    *redis.Client
    limit    int           // Max tokens
    window   time.Duration // Refill window
}

func (rl *RateLimiter) Allow(ctx context.Context, key string) (bool, RateLimitInfo, error) {
    now := time.Now().Unix()
    windowKey := fmt.Sprintf("ratelimit:%s:%d", key, now/int64(rl.window.Seconds()))

    pipe := rl.redis.Pipeline()
    incr := pipe.Incr(ctx, windowKey)
    pipe.Expire(ctx, windowKey, rl.window)
    _, err := pipe.Exec(ctx)
    if err != nil {
        return false, RateLimitInfo{}, err
    }

    count := incr.Val()
    remaining := rl.limit - int(count)
    if remaining < 0 {
        remaining = 0
    }

    info := RateLimitInfo{
        Limit:     rl.limit,
        Remaining: remaining,
        Reset:     time.Unix(((now/int64(rl.window.Seconds()))+1)*int64(rl.window.Seconds()), 0),
    }

    return count <= int64(rl.limit), info, nil
}

type RateLimitInfo struct {
    Limit     int
    Remaining int
    Reset     time.Time
}

Middleware

func rateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Key by API key, user ID, or IP
            key := extractRateLimitKey(r)

            allowed, info, err := limiter.Allow(r.Context(), key)
            if err != nil {
                http.Error(w, "Internal Server Error", 500)
                return
            }

            // Always set rate limit headers
            w.Header().Set("X-RateLimit-Limit", strconv.Itoa(info.Limit))
            w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(info.Remaining))
            w.Header().Set("X-RateLimit-Reset", strconv.FormatInt(info.Reset.Unix(), 10))

            if !allowed {
                retryAfter := int(time.Until(info.Reset).Seconds())
                w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
                w.WriteHeader(http.StatusTooManyRequests)
                json.NewEncoder(w).Encode(map[string]interface{}{
                    "type":        "https://api.example.com/errors/rate-limit",
                    "title":       "Rate Limit Exceeded",
                    "status":      429,
                    "detail":      fmt.Sprintf("Rate limit of %d requests per hour exceeded", info.Limit),
                    "retry_after": retryAfter,
                })
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

Tiered Rate Limits

Tier Requests/Hour Burst Use Case
Free 100 10/min Trial users
Basic 1,000 100/min Paid individuals
Pro 10,000 500/min Teams
Enterprise 100,000 2,000/min Custom SLA

Input Validation

Validate at the Boundary

// Use a validation library, not manual checks
import "github.com/go-playground/validator/v10"

type CreateUserRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"omitempty,min=13,max=150"`
    Website  string `json:"website" validate:"omitempty,url"`
    Role     string `json:"role" validate:"required,oneof=admin member viewer"`
    Password string `json:"password" validate:"required,min=8,max=128"`
}

var validate = validator.New()

func handleCreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondError(w, 400, "Invalid JSON body")
        return
    }

    if err := validate.Struct(req); err != nil {
        validationErrors := err.(validator.ValidationErrors)
        respondValidationErrors(w, validationErrors)
        return
    }

    // Input is now validated - proceed
}

Validation Checklist

Check Why
Max request body size Prevent memory exhaustion
String length limits Prevent storage abuse
Enum validation Reject unknown values
URL validation Prevent SSRF (whitelist schemes)
Email format Reject obviously invalid
Numeric bounds Prevent overflow, nonsensical values
Array max length Prevent excessive processing
Nested object depth Prevent deep recursion
Content-Type validation Ensure expected format
UTF-8 validation Prevent encoding attacks

Schema Validation (OpenAPI)

import "github.com/getkin/kin-openapi/openapi3filter"

// Validate requests against OpenAPI spec automatically
router, _ := gorillamux.NewRouter(spec)

func validationMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        route, pathParams, _ := router.FindRoute(r)

        input := &openapi3filter.RequestValidationInput{
            Request:    r,
            PathParams: pathParams,
            Route:      route,
        }

        if err := openapi3filter.ValidateRequest(r.Context(), input); err != nil {
            respondError(w, 400, err.Error())
            return
        }

        next.ServeHTTP(w, r)
    })
}

API Versioning and Deprecation

Deprecation Timeline

1. Announce deprecation (minimum 6 months before removal)
   - Add Deprecation header to responses
   - Update API documentation
   - Email API key owners

2. Warning period (3-6 months)
   Deprecation: true
   Sunset: Sat, 15 Jun 2025 00:00:00 GMT
   Link: <https://docs.example.com/migration-guide>; rel="deprecation"

3. Migration support
   - Provide migration guide
   - Offer parallel running of old and new versions
   - Log deprecated endpoint usage for targeted outreach

4. Removal
   - Return 410 Gone with migration info
   - Keep 410 response for 6+ months

Sunset Header (RFC 8594)

HTTP/1.1 200 OK
Sunset: Sat, 15 Jun 2025 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/v3/users>; rel="successor-version"

Transport Security

TLS Configuration

tlsConfig := &tls.Config{
    MinVersion: tls.VersionTLS12,
    CipherSuites: []uint16{
        tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
        tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
        tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
    },
    PreferServerCipherSuites: true,
}

server := &http.Server{
    TLSConfig: tlsConfig,
    // ...
}

Security Headers

func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
        w.Header().Set("Cache-Control", "no-store")   // For API responses with sensitive data
        w.Header().Set("X-Request-ID", generateRequestID())
        next.ServeHTTP(w, r)
    })
}

OWASP API Security Top 10

2023 Edition

# Risk Description Prevention
1 Broken Object-Level Auth (BOLA) User accesses other users' objects via ID manipulation Check ownership in every endpoint: WHERE id = ? AND user_id = ?
2 Broken Authentication Weak auth, credential stuffing, missing rate limits on login Rate limit login, use strong password hashing (argon2id), MFA
3 Broken Object Property-Level Auth Mass assignment, excessive data exposure Explicit allowlists for input fields, separate input/output DTOs
4 Unrestricted Resource Consumption No rate limits, unbounded queries, large payloads Rate limiting, pagination limits, request size limits, timeouts
5 Broken Function-Level Auth Admin endpoints accessible to regular users Role-based access control, deny by default, test auth on every endpoint
6 Unrestricted Access to Sensitive Business Flows Automated abuse (ticket scalping, spam) Rate limiting, CAPTCHA, device fingerprinting, business logic limits
7 Server-Side Request Forgery (SSRF) API fetches attacker-controlled URLs Validate/whitelist URLs, block internal networks, use allowlists
8 Security Misconfiguration Default configs, verbose errors, missing CORS Harden defaults, strip stack traces in production, audit configs
9 Improper Inventory Management Shadow APIs, deprecated endpoints still active API gateway, version inventory, automated discovery, sunset old versions
10 Unsafe Consumption of APIs Trusting third-party API responses without validation Validate all external API responses, set timeouts, use TLS

BOLA Prevention (Most Common API Vulnerability)

// BAD: Only checks if resource exists
func getOrder(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")
    order, _ := db.GetOrder(orderID)  // Anyone can access any order!
    json.NewEncoder(w).Encode(order)
}

// GOOD: Checks ownership
func getOrder(w http.ResponseWriter, r *http.Request) {
    orderID := chi.URLParam(r, "id")
    userID := r.Context().Value(userIDKey).(string)

    order, err := db.GetOrderForUser(orderID, userID)
    // SQL: SELECT * FROM orders WHERE id = $1 AND user_id = $2
    if err != nil {
        respondError(w, 404, "Order not found")  // 404, not 403 (don't leak existence)
        return
    }
    json.NewEncoder(w).Encode(order)
}

Mass Assignment Prevention

// BAD: Binding all fields from request
func updateUser(w http.ResponseWriter, r *http.Request) {
    var user User
    json.NewDecoder(r.Body).Decode(&user)  // Attacker sets role=admin!
    db.Save(&user)
}

// GOOD: Explicit allowlist of updatable fields
type UpdateUserInput struct {
    Name   *string `json:"name"`
    Email  *string `json:"email"`
    // role is NOT here - cannot be set via API
}

func updateUser(w http.ResponseWriter, r *http.Request) {
    var input UpdateUserInput
    json.NewDecoder(r.Body).Decode(&input)

    user, _ := db.GetUser(userID)
    if input.Name != nil {
        user.Name = *input.Name
    }
    if input.Email != nil {
        user.Email = *input.Email
    }
    db.Save(&user)
}