Go’s simplicity is its greatest strength and, I’d argue, its most dangerous security property. The language has no exceptions, no generics-based abstractions (until recently), and no implicit behaviour, everything is explicit. But that explicitness creates its own class of vulnerabilities: unchecked errors that silently skip security validation, goroutine races on shared state, HTTP client defaults that follow redirects into internal networks, and string handling patterns that bypass input validation. In this post, I want to walk through the Go-specific anti-patterns that lead to security vulnerabilities, from the error that nobody checked to the goroutine that corrupted the authentication cache. The more I dug into Go’s security landscape, the more I realised these bugs are subtle precisely because the language feels so straightforward.

Unchecked Errors: The Silent Security Bypass

Go returns errors as values. The language doesn’t force you to handle them. Here’s the thing, an unchecked error in a security-critical path means the program continues with a zero value, and that zero value is often a valid value that bypasses the intended check. This pattern is responsible for some genuinely surprising security issues.

The Easy-to-Spot Version

package main

import (
    "crypto/x509"
    "encoding/pem"
    "net/http"
)

func verifyClientCert(r *http.Request) bool {
    certHeader := r.Header.Get("X-Client-Cert")
    block, _ := pem.Decode([]byte(certHeader))
    cert, _ := x509.ParseCertificate(block.Bytes)
    return cert.Subject.CommonName == "trusted-service"
}

Both pem.Decode and x509.ParseCertificate can fail. If pem.Decode returns nil for block, the next line panics on block.Bytes. But if the error handling is slightly different, say, the function returns false on panic via a deferred recover, the function silently returns false, which might be the “deny” path. The real danger is when the zero value is the “allow” path.

The Hard-to-Spot Version

package main

import (
    "database/sql"
    "net/http"
    "strconv"
)

func getUser(db *sql.DB, r *http.Request) (*User, error) {
    idStr := r.URL.Query().Get("id")
    id, _ := strconv.Atoi(idStr)

    var user User
    err := db.QueryRow("SELECT id, name, role FROM users WHERE id = ?", id).
        Scan(&user.ID, &user.Name, &user.Role)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

This one is a great example of how subtle Go bugs can be. strconv.Atoi fails on non-numeric input and returns 0 as the zero value. The _ discards the error. The query executes with id = 0, which may return the admin user (ID 0) or the first user in the table. The attacker sends id=abc and gets a different user’s data. The unchecked error converts an invalid input into a valid query with an unintended parameter. I’ve run into this exact pattern in a code review where the admin account was ID 0, anyone could access it by sending garbage input.

Comparison: Rust’s Approach

Rust’s Result type forces error handling at compile time. You can’t use the value without unwrapping the Result:

// Rust: Must handle the error to get the value
fn get_user_id(query: &str) -> Result<i64, ParseIntError> {
    let id: i64 = query.parse()?; // ? propagates the error
    Ok(id)
}
// Cannot accidentally use a zero value, the error must be handled

Go’s errcheck linter catches unchecked errors, but it’s not enabled by default and many projects don’t use it. Pushing for it in CI pipelines is worth the effort.

Goroutine Race Conditions

Go makes concurrency easy with goroutines and channels. That ease leads to race conditions when developers share state between goroutines without proper synchronization. These bugs are intermittent and hard to reproduce, which makes them particularly dangerous.

The Easy-to-Spot Version

package main

import (
    "net/http"
    "sync"
)

var (
    sessions = make(map[string]string)
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
    token := generateToken()
    username := r.FormValue("username")
    sessions[token] = username // Concurrent map write, race condition
    http.SetCookie(w, &http.Cookie{Name: "session", Value: token})
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cookie, _ := r.Cookie("session")
        if cookie == nil {
            http.Error(w, "unauthorized", 401)
            return
        }
        username := sessions[cookie.Value] // Concurrent map read, race condition
        if username == "" {
            http.Error(w, "unauthorized", 401)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Go’s maps are not safe for concurrent access. Concurrent reads and writes cause a runtime panic (fatal error: concurrent map read and map write). In a web server, every request runs in its own goroutine, so the session map is accessed concurrently by every request. The fix is sync.RWMutex or sync.Map. This works fine in development with one user, then crashes when real traffic hits, a classic “works on my machine” scenario.

The Hard-to-Spot Version

package main

import (
    "sync"
    "time"
)

type RateLimiter struct {
    mu       sync.Mutex
    counts   map[string]int
    window   time.Duration
}

func NewRateLimiter(window time.Duration) *RateLimiter {
    rl := &RateLimiter{
        counts: make(map[string]int),
        window: window,
    }
    go rl.cleanup()
    return rl
}

func (rl *RateLimiter) Allow(key string, limit int) bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()
    rl.counts[key]++
    return rl.counts[key] <= limit
}

func (rl *RateLimiter) cleanup() {
    for {
        time.Sleep(rl.window)
        rl.counts = make(map[string]int) // Race: replaces map without lock
    }
}

What I find particularly interesting about this one is that the Allow method correctly uses the mutex. The developer clearly knows about synchronization. But the cleanup goroutine replaces the entire map without holding the lock. Between the Lock() in Allow and the map access, the cleanup goroutine can replace rl.counts with a new map. The Allow method then increments a counter in the old map (which is being garbage collected) while subsequent calls use the new map. The rate limiter resets unpredictably, allowing bursts of requests through. This kind of bug can defeat brute-force protection in a login system, the synchronization looks correct at first glance, which is what makes it so dangerous.

The fix is to hold the lock in cleanup:

func (rl *RateLimiter) cleanup() {
    for {
        time.Sleep(rl.window)
        rl.mu.Lock()
        rl.counts = make(map[string]int)
        rl.mu.Unlock()
    }
}

Comparison: Java’s Concurrent Collections

Java provides thread-safe collections out of the box:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

// Java: ConcurrentHashMap handles synchronization internally
ConcurrentHashMap<String, AtomicInteger> counts = new ConcurrentHashMap<>();

public boolean allow(String key, int limit) {
    AtomicInteger count = counts.computeIfAbsent(key, k -> new AtomicInteger(0));
    return count.incrementAndGet() <= limit;
}

Go’s standard library provides sync.Map for simple concurrent access patterns, but it’s not a drop-in replacement for all map usage patterns. It would be nice if Go had something closer to Java’s ConcurrentHashMap.

HTTP Client Defaults: SSRF by Configuration

Go’s http.DefaultClient follows redirects (up to 10) and has no timeout. Both defaults create security vulnerabilities.

The Vulnerable Pattern

package main

import (
    "io"
    "net/http"
)

func fetchURL(target string) ([]byte, error) {
    resp, err := http.Get(target) // Uses DefaultClient, follows redirects, no timeout
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

An attacker provides target=http://evil.com/redirect which redirects to http://169.254.169.254/latest/meta-data/ (the cloud metadata endpoint). The default client follows the redirect and returns the cloud credentials. The attacker never directly requests the internal URL, the redirect does it for them. Reading through SSRF write-ups on bug bounty platforms, this redirect-based pattern is one of the most common techniques, and Go’s defaults make it particularly easy to exploit.

The Fix

package main

import (
    "fmt"
    "io"
    "net"
    "net/http"
    "time"
)

var safeClient = &http.Client{
    Timeout: 10 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if len(via) >= 3 {
            return fmt.Errorf("too many redirects")
        }
        if isInternalAddress(req.URL.Hostname()) {
            return fmt.Errorf("redirect to internal address blocked")
        }
        return nil
    },
}

func isInternalAddress(host string) bool {
    ip := net.ParseIP(host)
    if ip == nil {
        addrs, err := net.LookupIP(host)
        if err != nil || len(addrs) == 0 {
            return true // Block on resolution failure
        }
        ip = addrs[0]
    }
    privateRanges := []string{
        "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
        "169.254.0.0/16", "127.0.0.0/8",
    }
    for _, cidr := range privateRanges {
        _, network, _ := net.ParseCIDR(cidr)
        if network.Contains(ip) {
            return true
        }
    }
    return false
}

func fetchURL(target string) ([]byte, error) {
    resp, err := safeClient.Get(target)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

Comparison: Python’s requests Library

Python’s requests library also follows redirects by default, but the pattern for disabling them is more visible:

import requests

# Python: Redirect following is explicit and easy to disable
resp = requests.get(url, allow_redirects=False, timeout=10)

Go requires creating a custom http.Client with a CheckRedirect function, which is more code but provides finer-grained control. Go’s approach is arguably better for security, it forces you to think about redirect behaviour explicitly.

Slice Header Aliasing

Go slices are backed by arrays. Multiple slices can share the same underlying array. Appending to a slice may or may not create a new array, depending on capacity. This creates subtle bugs where modifications to one slice affect another. This is one of those Go-specific gotchas that took me a while to fully internalize.

The Vulnerable Pattern

package main

import "fmt"

func filterAdminRoutes(routes []string) []string {
    filtered := routes[:0] // Shares underlying array with routes
    for _, r := range routes {
        if r != "/admin" && r != "/admin/users" {
            filtered = append(filtered, r)
        }
    }
    return filtered
}

func main() {
    allRoutes := []string{"/home", "/admin", "/api", "/admin/users", "/health"}
    publicRoutes := filterAdminRoutes(allRoutes)
    fmt.Println("Public:", publicRoutes)
    fmt.Println("All:", allRoutes) // allRoutes is corrupted!
}

routes[:0] creates a slice with length 0 but the same underlying array as routes. The append calls overwrite elements of the original routes slice. After filtering, allRoutes contains corrupted data. If allRoutes is used elsewhere for authorization checks, the corruption can bypass access controls. I found a variant of this in a code review, a routing middleware that was supposed to enforce admin-only paths was silently corrupting the route table.

The Fix

func filterAdminRoutes(routes []string) []string {
    filtered := make([]string, 0, len(routes)) // New underlying array
    for _, r := range routes {
        if r != "/admin" && r != "/admin/users" {
            filtered = append(filtered, r)
        }
    }
    return filtered
}

Comparison: Rust’s Ownership Model

Rust prevents this class of bug entirely through ownership:

fn filter_admin_routes(routes: Vec<String>) -> Vec<String> {
    routes.into_iter()
        .filter(|r| r != "/admin" && r != "/admin/users")
        .collect()
    // Original routes is consumed, cannot be used after this call
}

The original routes vector is moved into the function. The caller can’t access it after the call, eliminating the aliasing problem. This is one of those areas where Rust’s ownership model really shines.

Defer and Resource Cleanup Ordering

Go’s defer executes in LIFO order when the function returns. Incorrect defer ordering can leave resources in an inconsistent state, creating security windows in file permission handling.

The Vulnerable Pattern

func processFile(path string) error {
    f, err := os.OpenFile(path, os.O_RDWR, 0600)
    if err != nil {
        return err
    }
    defer f.Close()

    if err := f.Chmod(0644); err != nil { // Makes file world-readable
        return err
    }
    defer f.Chmod(0600) // Intended to restore permissions, but runs AFTER Close

    // Process file with relaxed permissions...
    data, err := io.ReadAll(f)
    if err != nil {
        return err // Chmod(0600) runs, then Close runs, but if processing panics
                    // between Chmod(0644) and here, the file stays world-readable
    }
    return nil
}

If the function panics or returns early between Chmod(0644) and the deferred Chmod(0600), the file remains world-readable. The deferred restore runs on normal return, but the window between the permission change and the restore is a TOCTOU (time-of-check-time-of-use) gap. This kind of issue can leave sensitive config files exposed in production.

Detection Strategies

Tool What It Catches Limitations
go vet Some race conditions, unreachable code, incorrect format strings Limited security-specific checks
go test -race Data races at runtime Requires test execution that triggers the race; does not catch all races
gosec SQL injection, hardcoded credentials, weak crypto, file permissions Pattern-based; misses indirect vulnerabilities
staticcheck Unused results, incorrect API usage, some concurrency issues Not security-focused
errcheck Unchecked error returns Does not assess security impact of unchecked errors
Semgrep Pattern-based detection of SSRF, injection, misconfigurations Requires Go-specific rules

Manual Review Checklist

Here’s a checklist I’ve put together for Go security reviews:

  1. Search for _ = and bare _ on error returns, every unchecked error in a security path is a potential bypass. This is the number one Go security heuristic.
  2. Search for map access without sync.Mutex or sync.Map, any map shared between goroutines needs synchronization.
  3. Search for http.Get, http.DefaultClient, verify custom clients with timeouts and redirect checks are used. The defaults are dangerous.
  4. Search for [:0] slice expressions, verify the result doesn’t alias security-critical data.
  5. Search for defer in functions that change permissions or state, verify the ordering handles all exit paths.
  6. Search for strconv.Atoi and strconv.ParseInt, verify the error is checked before using the value. The zero-value trap is real.
  7. Search for os.Exec and exec.Command, verify arguments are not constructed from user input.

Remediation Patterns

Always Check Errors on Security Paths

func getUser(db *sql.DB, r *http.Request) (*User, error) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        return nil, fmt.Errorf("invalid user ID: %w", err)
    }
    if id <= 0 {
        return nil, fmt.Errorf("user ID must be positive")
    }

    var user User
    err = db.QueryRow("SELECT id, name, role FROM users WHERE id = ?", id).
        Scan(&user.ID, &user.Name, &user.Role)
    if err != nil {
        return nil, fmt.Errorf("user lookup failed: %w", err)
    }
    return &user, nil
}

Use sync.RWMutex for Shared State

type SafeSessionStore struct {
    mu       sync.RWMutex
    sessions map[string]string
}

func (s *SafeSessionStore) Set(token, username string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.sessions[token] = username
}

func (s *SafeSessionStore) Get(token string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    username, ok := s.sessions[token]
    return username, ok
}

Create a Safe HTTP Client

var httpClient = &http.Client{
    Timeout: 10 * time.Second,
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse // Do not follow redirects
    },
}

Key Takeaways

  1. Unchecked errors are security bypasses. Go’s error-as-value pattern means every _ on an error return is a potential vulnerability. Use errcheck in CI. A single ignored error can lead to critical auth bypasses.
  2. Maps are not goroutine-safe. Any map accessed from multiple goroutines needs sync.Mutex, sync.RWMutex, or sync.Map. This is one of the most common Go production issues.
  3. http.DefaultClient follows redirects into internal networks. Always create a custom client with timeouts and redirect restrictions. The defaults make SSRF straightforward.
  4. Slice aliasing corrupts shared data. Use make() to create independent slices when the original must remain unchanged. This one is easy to miss.
  5. Go’s simplicity does not mean safety. The language eliminates some vulnerability classes (no eval, no deserialization RCE) but introduces others through its explicit error handling and concurrency model. Go bugs are subtle precisely because the language feels so straightforward, and that’s what makes them worth studying carefully.