Safeguard
Security Guides

Go Concurrency Security Pitfalls: When Data Races Become Vulnerabilities

A data race isn't just a flaky test — in the wrong place it's an auth bypass, a cross-request leak, or a denial of service. Here are the Go concurrency bugs that turn into security incidents.

Daniel Osei
Security Researcher
7 min read

Concurrency bugs get filed as "flaky" and quietly retried in CI until they pass. That instinct is exactly how they become security incidents. A data race on the wrong variable is not a cosmetic timing issue — it's a mechanism for one request to read another's data, for an authorization check to pass against stale state, or for a service to exhaust memory under a load pattern an attacker can trigger on purpose. Go makes concurrency easy enough that most services have far more of it than their authors track, and the language's memory model is explicit that a data race is undefined behavior, not "probably fine." This guide covers the concurrency patterns that cross the line from bug to vulnerability, mapped to CWE-362 (race condition) and CWE-367 (TOCTOU), with the fix for each.

Why should a security engineer care about data races at all?

Because a race on shared, security-relevant state can be steered by an attacker into disclosing data or bypassing a check.

Consider a handler that reuses a struct across requests through a package-level variable:

// VULNERABLE: currentUser is shared across all requests
var currentUser *User

func handler(w http.ResponseWriter, r *http.Request) {
    currentUser = authenticate(r) // one shared slot, all goroutines
    if currentUser.IsAdmin {
        renderAdmin(w, currentUser)
    }
}

Under concurrent load, request A can authenticate, and before it reaches the IsAdmin check, request B (a different user) overwrites currentUser. Now A's admin check reads B's identity — or A renders B's data. This is a real class of bug, not a contrived one; it appears whenever request-scoped state leaks into shared scope. The fix is to keep per-request state per-request: a local variable, or the *http.Request context — never a package global.

func handler(w http.ResponseWriter, r *http.Request) {
    user := authenticate(r) // local to this goroutine
    if user.IsAdmin {
        renderAdmin(w, user)
    }
}

What's the single most important tool for catching these?

The race detector. Build and test with -race, and treat any report as a defect that blocks merge.

go test -race ./...
go build -race -o app-race ./cmd/app   # exercise under load in staging

The detector instruments memory access at runtime and reports the two goroutines and stacks involved when it observes an unsynchronized read/write to the same address. It has effectively zero false positives — if it fires, you have undefined behavior. The catch is that it only detects races that actually execute during the run, so coverage matters: run your full test suite under -race in CI, and run a race-instrumented binary through a realistic load test in staging (the instrumentation adds CPU and memory overhead, so it's a staging tool, not a production one). A race that only appears at 500 concurrent requests won't show up in a single-threaded unit test.

How do TOCTOU bugs show up in Go?

Time-of-check to time-of-use races appear whenever you check a condition and then act on it as two separate, unsynchronized steps.

The classic filesystem form: check that a path is safe, then open it — an attacker swaps the file in between.

// VULNERABLE (CWE-367): the file can change between Stat and Open
if info, _ := os.Stat(path); !info.IsDir() {
    f, _ := os.Open(path) // path may now point elsewhere
    // ...
}

But the more common Go form is in application logic: check a balance, then debit; check a rate limit, then increment; check that a resource is unclaimed, then claim it. If those two steps aren't atomic, concurrent requests interleave and you get double-spends or limit bypasses. The fix is to make check-and-act a single atomic operation — a database transaction with the right isolation level or a SELECT ... FOR UPDATE, an atomic compare-and-swap via sync/atomic, or a mutex held across both steps. For files, prefer operating on an open file handle (os.OpenFile with the right flags) so you act on the object you validated, and use os.Root (Go 1.24+) to confine access.

Why are goroutine leaks a denial-of-service risk?

Because a goroutine blocked forever holds memory and often a connection, and if an attacker controls the trigger they can exhaust the server.

The canonical leak: launch a goroutine that writes to an unbuffered channel, but the reader goes away (timeout, client disconnect) and nothing ever reads:

// LEAK: if the caller times out, this goroutine blocks on send forever
func fetch(ctx context.Context) (Result, error) {
    ch := make(chan Result) // unbuffered
    go func() {
        ch <- slowWork() // blocks forever if no one receives
    }()
    select {
    case r := <-ch:
        return r, nil
    case <-ctx.Done():
        return Result{}, ctx.Err() // goroutine above is now orphaned
    }
}

Each timed-out request leaks one goroutine and whatever slowWork holds. An attacker who can cause timeouts (slow upstream, deliberate disconnects) grows that unboundedly. Fixes: give the channel a buffer of 1 so the send never blocks, and — more importantly — propagate ctx into slowWork so cancellation actually stops the work rather than orphaning it. Every goroutine should have a clear termination condition; "fire and forget" on a request path is a leak waiting for a load pattern.

What about maps, and the "loop variable" bug?

Concurrent writes to a built-in map will crash the process, and the pre-1.22 loop-variable capture bug silently shared state.

Go's built-in map is not safe for concurrent use — concurrent writes trigger a fatal concurrent map writes panic that takes the whole process down (a DoS if attacker-triggerable). Guard shared maps with a sync.RWMutex or use sync.Map for the specific read-heavy patterns it's built for. Separately, code written for Go before 1.22 that launched goroutines inside a for loop and captured the loop variable shared a single variable across iterations — a subtle correctness-and-security bug. Go 1.22 (2024) changed loop semantics so each iteration gets its own variable, fixing the default; if you maintain older code or set an older go directive in go.mod, the old behavior still applies, so audit those loops.

The concurrency security checklist

PitfallCWEFix
Request state in package globals362Keep state local / in context
Unsynchronized shared reads/writes362Mutex, atomic, or channel ownership
Check-then-act (balance, limit)367Transaction / CAS / mutex across both
Goroutine leak on timeout400Buffer channel + propagate ctx
Concurrent map writes362sync.RWMutex / sync.Map
Pre-1.22 loop var capture362Upgrade go directive; audit loops

Run go test -race ./... as a required CI gate and cover hot paths with a race-instrumented load test.

How Safeguard Helps

Concurrency vulnerabilities are behavioral — they emerge under load, which is why they slip past code review and version-based scanners alike. Safeguard's dynamic testing (DAST) drives your running Go service under concurrent load to surface race-triggered auth bypasses and resource exhaustion that only appear when goroutines actually collide. Software composition analysis flags dependencies with known concurrency-related CVEs, and Griffin, the AI analysis engine, reviews flagged code paths and explains where shared state crosses a request boundary. For the broader set of runtime controls, pair this with our Go web application security checklist.

Stress-test your Go service free at app.safeguard.sh/register, with docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.