CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/go-security-basics

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

1.32x
Quality

83%

Does it follow best practices?

Impact

99%

1.32x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/go-security-basics/

name:
go-security-basics
description:
Security defaults that belong in every Go HTTP server from day one. CORS, security headers, rate limiting, input validation, SQL injection prevention, CSRF protection, TLS configuration, secrets management, graceful shutdown, and request timeouts. Apply these whenever you create or modify any Go web service -- do not wait for a security review or explicit request.
keywords:
go security, golang cors, rate limiting, security headers, input validation, go middleware, sql injection, csrf, tls, graceful shutdown, request timeout, secrets, environment variables, rs/cors, x/time/rate, database/sql, go web security, cors middleware, go http server
license:
MIT

Go Security Basics

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.


The 10 Things Every Go HTTP Server Needs

  1. CORS middleware -- configured with explicit origins using rs/cors, not a hand-rolled wildcard
  2. Security headers -- X-Content-Type-Options, X-Frame-Options, Referrer-Policy, HSTS
  3. Rate limiting -- per-IP using golang.org/x/time/rate, stricter on auth endpoints
  4. Input validation -- validate all user input before use, reject invalid data with 400
  5. SQL injection prevention -- always use parameterized queries with $1 placeholders, never string concatenation
  6. CSRF protection -- for any endpoint using cookie-based authentication
  7. TLS configuration -- proper TLS 1.2+ config with secure cipher suites
  8. Secrets from environment -- never hardcode secrets, always use os.Getenv
  9. Graceful shutdown -- handle SIGTERM/SIGINT, drain connections before exit
  10. Request timeouts -- read, write, idle timeouts on the server plus per-request context timeouts

These ten items are as fundamental as http.ListenAndServe. If your app starts a server but lacks these, it is not ready for any environment.


1. CORS Middleware (rs/cors)

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/cors

WRONG -- wildcard origin allows any site to call your API:

handler := cors.AllowAll().Handler(mux) // Access-Control-Allow-Origin: * -- dangerous

WRONG -- 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:

  • Never use cors.AllowAll() or set AllowedOrigins: []string{"*"} in production
  • Always read allowed origins from environment variables
  • Set AllowCredentials: true only if you use cookies or Authorization headers
  • Set MaxAge to reduce preflight request overhead

2. Security Headers Middleware

WRONG -- 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 sniffing
  • X-Frame-Options: DENY -- prevents clickjacking by blocking iframes
  • Referrer-Policy -- controls how much URL info is sent to other sites
  • X-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 access
  • Strict-Transport-Security -- enforces HTTPS for future visits

3. Rate Limiting (golang.org/x/time/rate)

go get golang.org/x/time/rate

WRONG -- no rate limiting at all:

mux.HandleFunc("POST /api/auth/login", loginHandler) // Unlimited login attempts

RIGHT -- 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.


4. Input Validation

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...
}

Request Body Size Limit

WRONG -- accepting unlimited body sizes:

json.NewDecoder(r.Body).Decode(&req) // No size limit -- attacker can send gigabytes

RIGHT -- 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)

5. SQL Injection Prevention

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 userID

RIGHT -- 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:

  • ALWAYS use $1, $2, etc. (PostgreSQL) or ? (SQLite/MySQL) for ALL user-supplied values
  • NEVER use fmt.Sprintf, +, or strings.Builder to build SQL with user input
  • NEVER interpolate user input into ORDER BY, LIMIT, or table names -- whitelist these values

6. CSRF Protection for Cookie-Based Auth

If 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
})

7. TLS Configuration

WRONG -- using default TLS config or old TLS versions:

http.ListenAndServeTLS(":443", "cert.pem", "key.pem", handler) // Default TLS config allows TLS 1.0

RIGHT -- 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()
}

8. Secrets from Environment

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:

  • NEVER hardcode passwords, API keys, JWT secrets, or database URLs in source code
  • Use os.Getenv or a config library to read them from the environment
  • Fail fast at startup if required secrets are missing
  • Use a .env file for local development (with .env in .gitignore)

9. Graceful Shutdown

WRONG -- hard exit that drops in-flight requests:

log.Fatal(http.ListenAndServe(":8080", handler)) // Ctrl+C kills all connections instantly

RIGHT -- 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")
}

10. Request Timeouts

WRONG -- no timeouts (slow clients can hold connections forever):

srv := &http.Server{Addr: ":8080", Handler: handler}
srv.ListenAndServe() // No timeouts -- Slowloris attacks, resource exhaustion

RIGHT -- 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)

Middleware Wiring Order

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,
}

Checklist -- Apply to Every Go HTTP Server

This is not a "production checklist." These belong in every Go HTTP server from the start:

  • CORS configured with rs/cors and explicit allowed origins (never cors.AllowAll() or "*")
  • Security headers middleware (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
  • Rate limiting with golang.org/x/time/rate, stricter on auth endpoints
  • Request body size limited with http.MaxBytesReader
  • All user input validated before use (path params, query params, body fields)
  • SQL queries use parameterized placeholders ($1, ?) -- never string concatenation
  • Secrets loaded from environment variables, never hardcoded
  • Graceful shutdown with signal handling and connection draining
  • Server timeouts set (ReadTimeout, WriteTimeout, IdleTimeout)
  • Cookies have HttpOnly, Secure, SameSite flags when using session auth

If 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.

Verifiers

  • cors-configured -- CORS with rs/cors and explicit origins on every Go HTTP server
  • security-headers -- Security headers middleware on every Go HTTP server
  • rate-limiting -- Rate limiting with x/time/rate on every Go HTTP server
  • input-validation -- Input validation and body size limits on every Go HTTP server
  • sql-injection-prevention -- Parameterized SQL queries on every Go HTTP server
  • secrets-from-env -- Secrets from environment variables on every Go HTTP server
  • graceful-shutdown -- Graceful shutdown on every Go HTTP server
  • server-timeouts -- Server and request timeouts on every Go HTTP server

skills

go-security-basics

tile.json