# API Security Patterns ## Table of Contents - [API Key Management](#api-key-management) - [JWT (JSON Web Tokens)](#jwt-json-web-tokens) - [OAuth2 Flows](#oauth2-flows) - [CORS](#cors) - [Rate Limiting Implementation](#rate-limiting-implementation) - [Input Validation](#input-validation) - [API Versioning and Deprecation](#api-versioning-and-deprecation) - [Transport Security](#transport-security) - [OWASP API Security Top 10](#owasp-api-security-top-10) --- ## API Key Management ### Generation ```go 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") ``` ```go import "crypto/sha256" func hashAPIKey(key string) string { h := sha256.Sum256([]byte(key)) return hex.EncodeToString(h[:]) } ``` ### Scoping ```json { "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) ```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 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 ```go 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) ```go 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 ```go 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 ```go // 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) ```go 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: ; 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: ; rel="successor-version" ``` ## Transport Security ### TLS Configuration ```go 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 ```go 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) ```go // 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 ```go // 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) } ```