Security defaults that belong in every Go HTTP server from day one — CORS, security headers, rate limiting, SQL injection prevention, input validation, secrets management, graceful shutdown, and request timeouts.
89
83%
Does it follow best practices?
Impact
99%
1.32xAverage score across 5 eval scenarios
Passed
No known issues
Every Go HTTP server needs these security defaults from day one -- do not wait for a security review to add them. These are not optional hardening steps; they are baseline requirements for any Go web service, the same way you would not ship a car without seatbelts.
When to apply this skill: Every time you create a new Go HTTP server, add routes to an existing one, or refactor a Go web project. Even if the task only asks you to "build a REST API" or "create CRUD endpoints," you MUST include all of these. A Go HTTP server without them is incomplete.
rs/cors, not a hand-rolled wildcardgolang.org/x/time/rate, stricter on auth endpoints$1 placeholders, never string concatenationos.GetenvThese ten items are as fundamental as http.ListenAndServe. If your app starts a server but lacks these, it is not ready for any environment.
Use the rs/cors package. Do not hand-roll CORS middleware -- it is easy to get wrong and miss edge cases like preflight caching, credential handling, and header exposure.
go get github.com/rs/corsWRONG -- wildcard origin allows any site to call your API:
handler := cors.AllowAll().Handler(mux) // Access-Control-Allow-Origin: * -- dangerousWRONG -- hand-rolled CORS that misses edge cases:
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") // Dangerous wildcard
next.ServeHTTP(w, r)
})
}RIGHT -- explicit allowed origins from environment:
import "github.com/rs/cors"
func newCORSMiddleware() *cors.Cors {
allowedOrigins := strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",")
if len(allowedOrigins) == 0 || allowedOrigins[0] == "" {
allowedOrigins = []string{"http://localhost:5173"}
}
return cors.New(cors.Options{
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true, // Only if you use cookies/sessions
MaxAge: 300, // Cache preflight for 5 minutes
})
}
// Usage:
c := newCORSMiddleware()
handler := c.Handler(mux)Key rules:
cors.AllowAll() or set AllowedOrigins: []string{"*"} in productionAllowCredentials: true only if you use cookies or Authorization headersMaxAge to reduce preflight request overheadWRONG -- no security headers at all:
mux := http.NewServeMux()
// Routes defined without any security headers
http.ListenAndServe(":8080", mux)RIGHT -- security headers middleware before all routes:
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("X-XSS-Protection", "0") // Disable broken legacy filter
w.Header().Set("Content-Security-Policy", "default-src 'self'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
// HSTS -- only set when serving over TLS
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}What each header does:
X-Content-Type-Options: nosniff -- prevents MIME-type sniffingX-Frame-Options: DENY -- prevents clickjacking by blocking iframesReferrer-Policy -- controls how much URL info is sent to other sitesX-XSS-Protection: 0 -- disables broken legacy XSS filter (modern CSP is better)Content-Security-Policy -- restricts resource loading origins (prevents XSS)Permissions-Policy -- restricts browser feature accessStrict-Transport-Security -- enforces HTTPS for future visitsgo get golang.org/x/time/rateWRONG -- no rate limiting at all:
mux.HandleFunc("POST /api/auth/login", loginHandler) // Unlimited login attemptsRIGHT -- per-IP rate limiting with cleanup:
import (
"golang.org/x/time/rate"
"sync"
"time"
)
type RateLimiter struct {
visitors map[string]*visitor
mu sync.Mutex
rate rate.Limit
burst int
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: r,
burst: b,
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(time.Minute)
rl.mu.Lock()
for ip, v := range rl.visitors {
if time.Since(v.lastSeen) > 3*time.Minute {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
if !exists {
limiter := rate.NewLimiter(rl.rate, rl.burst)
rl.visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()}
return limiter
}
v.lastSeen = time.Now()
return v.limiter
}
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
// Strip port from RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if !rl.getLimiter(ip).Allow() {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"error":{"code":"RATE_LIMITED","message":"Too many requests"}}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}Create separate limiters for different endpoint tiers:
// General API: 100 requests/minute, burst 20
apiLimiter := NewRateLimiter(rate.Every(time.Minute/100), 20)
// Auth endpoints: 10 requests/minute, burst 5 (prevent brute force)
authLimiter := NewRateLimiter(rate.Every(time.Minute/10), 5)
mux.Handle("POST /api/auth/login", authLimiter.Middleware(http.HandlerFunc(loginHandler)))
mux.Handle("POST /api/auth/register", authLimiter.Middleware(http.HandlerFunc(registerHandler)))Always apply a stricter rate limit on auth and mutation endpoints than the general API limit.
WRONG -- trusting user input directly:
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Using id without validation -- could be anything
user, _ := db.Query("SELECT * FROM users WHERE id = " + id) // SQL injection!
}RIGHT -- validate all input before use:
func getUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
http.Error(w, `{"error":{"code":"INVALID_PARAM","message":"Invalid user ID"}}`, http.StatusBadRequest)
return
}
// Now use validated id with parameterized query
row := db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", id)
// ...
}
func createTask(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":{"code":"INVALID_JSON","message":"Invalid request body"}}`, http.StatusBadRequest)
return
}
// Validate required fields
req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
http.Error(w, `{"error":{"code":"VALIDATION_ERROR","message":"title is required"}}`, http.StatusBadRequest)
return
}
if len(req.Title) > 200 {
http.Error(w, `{"error":{"code":"VALIDATION_ERROR","message":"title must be 200 characters or less"}}`, http.StatusBadRequest)
return
}
// Use validated input...
}WRONG -- accepting unlimited body sizes:
json.NewDecoder(r.Body).Decode(&req) // No size limit -- attacker can send gigabytesRIGHT -- limit request body size:
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// Apply 1MB limit to all routes
handler := maxBodySize(1 << 20)(mux)This is the single most critical security rule for database access in Go. Always use parameterized queries with database/sql. Never concatenate user input into SQL strings.
WRONG -- string concatenation (SQL injection):
query := "SELECT * FROM users WHERE email = '" + email + "'"
db.Query(query) // Attacker sends: ' OR 1=1 --
query := fmt.Sprintf("SELECT * FROM tasks WHERE user_id = %s", userID)
db.Query(query) // SQL injection via userIDRIGHT -- parameterized queries with $1 placeholders:
// PostgreSQL uses $1, $2, etc.
row := db.QueryRow("SELECT id, name, email FROM users WHERE email = $1", email)
// For INSERT:
_, err := db.Exec(
"INSERT INTO tasks (title, description, user_id) VALUES ($1, $2, $3)",
task.Title, task.Description, userID,
)
// For UPDATE:
_, err := db.Exec(
"UPDATE tasks SET title = $1, completed = $2 WHERE id = $3 AND user_id = $4",
task.Title, task.Completed, taskID, userID,
)
// For DELETE:
_, err := db.Exec("DELETE FROM tasks WHERE id = $1 AND user_id = $2", taskID, userID)
// For IN clauses, build the placeholder list:
func buildPlaceholders(n int) string {
placeholders := make([]string, n)
for i := range placeholders {
placeholders[i] = fmt.Sprintf("$%d", i+1)
}
return strings.Join(placeholders, ", ")
}For SQLite, use ? instead of $1:
row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)Rules:
$1, $2, etc. (PostgreSQL) or ? (SQLite/MySQL) for ALL user-supplied valuesfmt.Sprintf, +, or strings.Builder to build SQL with user inputIf your API uses cookies for authentication (sessions, JWTs in cookies), you need CSRF protection. Token-only APIs (Authorization: Bearer) do not need CSRF.
RIGHT -- CSRF token middleware for cookie-based auth:
import "crypto/rand"
func generateCSRFToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func csrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
next.ServeHTTP(w, r)
return
}
// Validate CSRF token on state-changing requests
cookie, err := r.Cookie("csrf_token")
if err != nil {
http.Error(w, `{"error":{"code":"CSRF_ERROR","message":"Missing CSRF token"}}`, http.StatusForbidden)
return
}
headerToken := r.Header.Get("X-CSRF-Token")
if headerToken == "" || headerToken != cookie.Value {
http.Error(w, `{"error":{"code":"CSRF_ERROR","message":"Invalid CSRF token"}}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}Also set SameSite=Lax on all session cookies:
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: sessionID,
HttpOnly: true,
Secure: true, // HTTPS only
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: 86400, // 24 hours
})WRONG -- using default TLS config or old TLS versions:
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", handler) // Default TLS config allows TLS 1.0RIGHT -- configure TLS 1.2+ with secure defaults:
import "crypto/tls"
srv := &http.Server{
Addr: ":443",
Handler: handler,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.CurveP256,
tls.X25519,
},
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
},
}
srv.ListenAndServeTLS("cert.pem", "key.pem")For development, use http.ListenAndServe (no TLS), but always structure your code so TLS config is easy to enable:
if os.Getenv("TLS_CERT") != "" {
srv.ListenAndServeTLS(os.Getenv("TLS_CERT"), os.Getenv("TLS_KEY"))
} else {
srv.ListenAndServe()
}WRONG -- hardcoded secrets:
const jwtSecret = "my-secret-key" // Hardcoded in source
const dbPassword = "password123" // Will end up in version control
db, _ := sql.Open("postgres", "postgres://user:password123@localhost/mydb")RIGHT -- always read secrets from environment variables:
func mustGetEnv(key string) string {
val := os.Getenv(key)
if val == "" {
log.Fatalf("required environment variable %s is not set", key)
}
return val
}
// Usage
jwtSecret := mustGetEnv("JWT_SECRET")
dbURL := mustGetEnv("DATABASE_URL")
db, err := sql.Open("postgres", dbURL)Rules:
os.Getenv or a config library to read them from the environment.env file for local development (with .env in .gitignore)WRONG -- hard exit that drops in-flight requests:
log.Fatal(http.ListenAndServe(":8080", handler)) // Ctrl+C kills all connections instantlyRIGHT -- graceful shutdown that drains connections:
import (
"context"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// ... setup handler ...
srv := &http.Server{
Addr: ":" + getEnvOrDefault("PORT", "8080"),
Handler: handler,
}
// Start server in a goroutine
go func() {
log.Printf("Server starting on %s", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Give in-flight requests time to complete
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Forced shutdown: %v", err)
}
log.Println("Server stopped gracefully")
}WRONG -- no timeouts (slow clients can hold connections forever):
srv := &http.Server{Addr: ":8080", Handler: handler}
srv.ListenAndServe() // No timeouts -- Slowloris attacks, resource exhaustionRIGHT -- set read, write, and idle timeouts on the server:
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second, // Max time to read request headers + body
WriteTimeout: 10 * time.Second, // Max time to write response
IdleTimeout: 120 * time.Second, // Max time for keep-alive connections
}RIGHT -- per-request context timeout middleware:
func requestTimeout(timeout time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Usage: 30-second timeout per request
handler := requestTimeout(30 * time.Second)(mux)Pass the context to all downstream calls (database queries, HTTP clients):
row := db.QueryRowContext(r.Context(), "SELECT id, name FROM users WHERE id = $1", id)The order middleware is registered matters. Security middleware MUST come first.
mux := http.NewServeMux()
// Register routes on mux...
mux.HandleFunc("GET /api/tasks", listTasks)
mux.HandleFunc("POST /api/tasks", createTask)
mux.HandleFunc("GET /api/tasks/{id}", getTask)
mux.HandleFunc("PATCH /api/tasks/{id}", updateTask)
mux.HandleFunc("DELETE /api/tasks/{id}", deleteTask)
// Wrap with middleware (outermost runs first):
c := newCORSMiddleware()
handler := recoveryMiddleware(
securityHeaders(
c.Handler(
apiLimiter.Middleware(
requestTimeout(30*time.Second)(
maxBodySize(1<<20)(mux),
),
),
),
),
)
srv := &http.Server{
Addr: ":" + getEnvOrDefault("PORT", "8080"),
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}This is not a "production checklist." These belong in every Go HTTP server from the start:
rs/cors and explicit allowed origins (never cors.AllowAll() or "*")golang.org/x/time/rate, stricter on auth endpointshttp.MaxBytesReader$1, ?) -- never string concatenationIf the task says "build a REST API" or "create CRUD endpoints" and does not mention security, you still add all of the above. Security middleware is not a feature request -- it is part of building a Go HTTP server correctly.