Go Security: Goroutines, Error Handling, and Hidden Bugs
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:
- 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. - Search for map access without
sync.Mutexorsync.Map, any map shared between goroutines needs synchronization. - Search for
http.Get,http.DefaultClient, verify custom clients with timeouts and redirect checks are used. The defaults are dangerous. - Search for
[:0]slice expressions, verify the result doesn’t alias security-critical data. - Search for
deferin functions that change permissions or state, verify the ordering handles all exit paths. - Search for
strconv.Atoiandstrconv.ParseInt, verify the error is checked before using the value. The zero-value trap is real. - Search for
os.Execandexec.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
- Unchecked errors are security bypasses. Go’s error-as-value pattern means every
_on an error return is a potential vulnerability. Useerrcheckin CI. A single ignored error can lead to critical auth bypasses. - Maps are not goroutine-safe. Any map accessed from multiple goroutines needs
sync.Mutex,sync.RWMutex, orsync.Map. This is one of the most common Go production issues. http.DefaultClientfollows redirects into internal networks. Always create a custom client with timeouts and redirect restrictions. The defaults make SSRF straightforward.- Slice aliasing corrupts shared data. Use
make()to create independent slices when the original must remain unchanged. This one is easy to miss. - 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.