Skip to main content

Security Testing in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

Security testing in Go falls into three categories: writing targeted tests for security-sensitive code paths, using Go’s built-in fuzzer to find edge cases, and running static analysis tools to catch known vulnerability patterns. All three belong in CI.

The most important rule: security tests should be written by the same developers who write the code — not deferred to a separate security review. If a feature validates user input, the tests should include injection payloads. If a feature restricts access, the tests should include unauthorized access attempts.

For authentication/authorization implementation see Go authentication and authorization. For secure coding practices see Go secure coding practices.

Testing Input Validation

Input validation tests should include both the happy path and a comprehensive set of adversarial inputs. The same malicious inputs that real attackers use should be in your test suite:

func TestValidateEmail(t *testing.T) {
    tests := []struct {
        input   string
        wantErr bool
        name    string
    }{
        {"[email protected]", false, "standard"},
        {"[email protected]", false, "complex valid"},
        {"", true, "empty"},
        {"notanemail", true, "no @"},
        {"@example.com", true, "missing local part"},
        {"user@", true, "missing domain"},
        {"user @example.com", true, "space in local"},
        // XSS attempts in email fields
        {"<script>alert('xss')</script>@example.com", true, "XSS payload"},
        // Extremely long input
        {strings.Repeat("a", 500) + "@example.com", true, "too long"},
        // Null byte injection
        {"user\[email protected]", true, "null byte"},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            err := validateEmail(tc.input)
            if (err != nil) != tc.wantErr {
                t.Errorf("validateEmail(%q) error=%v wantErr=%v", tc.input, err, tc.wantErr)
            }
        })
    }
}

The adversarial cases — XSS payloads, null bytes, extreme lengths — should reject cleanly without panicking or producing unexpected behavior.

Testing SQL Injection Prevention

Parameterized queries prevent SQL injection at the database driver level, but you should verify your code actually uses them. Test with injection payloads and confirm the behavior is correct (query fails or returns no results) rather than executing the injected SQL:

func TestGetUserByEmailSQL(t *testing.T) {
    db := setupTestDB(t)  // creates an in-memory SQLite or test PostgreSQL DB

    // Insert a known user
    _, err := db.Exec("INSERT INTO users (email, name) VALUES ('[email protected]', 'Alice')")
    require.NoError(t, err)

    injectionPayloads := []string{
        "' OR '1'='1",
        "'; DROP TABLE users; --",
        "1 UNION SELECT * FROM users",
        "admin'--",
        "' OR 1=1 --",
    }

    for _, payload := range injectionPayloads {
        t.Run(payload, func(t *testing.T) {
            user, err := getUserByEmail(db, payload)
            // Should return "not found" — not the real user
            if user != nil {
                t.Errorf("SQL injection succeeded with payload %q: got user %+v", payload, user)
            }
            // Any error is acceptable (not found, invalid format)
            // What's NOT acceptable: returning a real user
        })
    }

    // Verify the table still exists (DROP TABLE didn't execute)
    var count int
    err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
    if err != nil {
        t.Fatalf("users table was dropped by injection: %v", err)
    }
    if count != 1 {
        t.Errorf("expected 1 user, got %d — injection may have altered data", count)
    }
}

This test serves two purposes: verifying injection payloads don’t return real users, and verifying destructive payloads don’t actually execute.

Testing Authentication

Authentication tests should cover the success path and every meaningful failure path:

func TestAuthentication(t *testing.T) {
    auth := NewAuthService(testDB)

    t.Run("valid credentials succeed", func(t *testing.T) {
        token, err := auth.Login("[email protected]", "correct-password")
        require.NoError(t, err)
        require.NotEmpty(t, token)
    })

    t.Run("wrong password fails", func(t *testing.T) {
        _, err := auth.Login("[email protected]", "wrong-password")
        require.Error(t, err)
        // Verify the error message doesn't reveal whether the email exists
        require.Equal(t, "invalid credentials", err.Error(),
            "error should not indicate whether email exists")
    })

    t.Run("unknown email fails with same message", func(t *testing.T) {
        _, err := auth.Login("[email protected]", "any-password")
        require.Error(t, err)
        require.Equal(t, "invalid credentials", err.Error(),
            "error for unknown user must match error for wrong password")
    })

    t.Run("empty credentials fail", func(t *testing.T) {
        _, err := auth.Login("", "")
        require.Error(t, err)
    })

    t.Run("bcrypt hashes differ for same password", func(t *testing.T) {
        h1, _ := hashPassword("password")
        h2, _ := hashPassword("password")
        require.NotEqual(t, h1, h2, "bcrypt must use different salts each time")
    })
}

The identical error message for “wrong password” and “unknown email” is a deliberate security property — it prevents user enumeration. Make it explicit in the test.

Testing Authorization

Authorization tests are the most often overlooked. Every access control boundary should have a test that tries to cross it:

func TestAuthorization(t *testing.T) {
    svc := NewOrderService(testDB)
    alice := &User{ID: "u1", Role: "user"}
    bob   := &User{ID: "u2", Role: "user"}
    admin := &User{ID: "u3", Role: "admin"}

    // Create Alice's order
    order, _ := svc.CreateOrder(alice, OrderRequest{Amount: 100})

    t.Run("owner can read own order", func(t *testing.T) {
        _, err := svc.GetOrder(alice, order.ID)
        require.NoError(t, err)
    })

    t.Run("other user cannot read Alice's order", func(t *testing.T) {
        _, err := svc.GetOrder(bob, order.ID)
        require.Error(t, err)
        // Should be a 403 Forbidden, not 404 Not Found
        // (returning 404 leaks existence information — a judgment call by security policy)
        var ae *AuthorizationError
        require.ErrorAs(t, err, &ae)
    })

    t.Run("admin can read any order", func(t *testing.T) {
        _, err := svc.GetOrder(admin, order.ID)
        require.NoError(t, err)
    })

    t.Run("user cannot promote to admin", func(t *testing.T) {
        err := svc.SetRole(alice, alice.ID, "admin")
        require.Error(t, err, "users must not be able to escalate their own privileges")
    })
}

Note the test verifying error types — authorization failures should return authorization errors, not generic “not found” errors (unless your security policy deliberately obscures existence).

Fuzzing: Finding the Edge Cases You Didn’t Think Of

Go 1.18+ ships a native fuzzer. Fuzz tests generate random mutations of seed inputs to find crashes and unexpected behavior:

// FuzzParseToken tests that token parsing never panics on arbitrary input
func FuzzParseToken(f *testing.F) {
    // Seed corpus: valid and near-valid inputs
    f.Add("valid.jwt.token")
    f.Add("")
    f.Add("a.b")
    f.Add(strings.Repeat("x", 10000))

    f.Fuzz(func(t *testing.T, input string) {
        // Should never panic — only return error or valid claims
        _, _ = parseJWTToken(input)
    })
}

// FuzzValidateAddress tests address parsing with arbitrary input
func FuzzValidateAddress(f *testing.F) {
    f.Add("123 Main St, Springfield, IL 62701")
    f.Add("")

    f.Fuzz(func(t *testing.T, input string) {
        // Must not panic, must not hang, must return in bounded time
        _, _ = parseAddress(input)
    })
}

Run fuzzing:

go test -fuzz=FuzzParseToken -fuzztime=30s ./...

The fuzzer records any input that causes a new code path in testdata/fuzz/FuzzParseToken/. These become part of the regression suite — re-run with go test and the corpus is tested automatically.

Fuzzing is most valuable for parsing functions, deserialization, and any code that handles untrusted input. It finds bugs that targeted tests miss because humans are bad at imagining every edge case.

Static Analysis: govulncheck and gosec

Add these to CI alongside go test:

# govulncheck: scan your dependency tree for known CVEs
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

# gosec: static analysis for common security anti-patterns
go install github.com/securego/gosec/v2/cmd/gosec@latest
gosec -exclude-dir=vendor ./...

govulncheck scans your go.sum against the Go vulnerability database and reports only vulnerabilities in code paths your program actually calls. It has very low false positives — any reported issue is worth addressing.

gosec catches patterns like:

  • Hardcoded credentials (G101)
  • Use of math/rand instead of crypto/rand for security-sensitive randomness (G404)
  • Unhandled errors from security-relevant functions (G104)
  • Use of weak cipher suites (G401)
  • Insecure file permissions (G304)

A typical CI integration:

# .github/workflows/security.yml
- name: Run govulncheck
  run: govulncheck ./...

- name: Run gosec
  run: gosec -fmt sarif -out gosec.sarif ./...
  
- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: gosec.sarif

Testing Cryptographic Code

Cryptographic functions have specific security properties to verify:

func TestEncryptionProperties(t *testing.T) {
    key := make([]byte, 32)
    rand.Read(key)

    plaintext := []byte("sensitive data")

    ct1, err := encrypt(key, plaintext)
    require.NoError(t, err)
    ct2, err := encrypt(key, plaintext)
    require.NoError(t, err)

    // Different nonce each call — ciphertexts must differ
    require.NotEqual(t, ct1, ct2,
        "encryption must use a random nonce; same plaintext must produce different ciphertext")

    // Both must decrypt correctly
    pt1, err := decrypt(key, ct1)
    require.NoError(t, err)
    require.Equal(t, plaintext, pt1)

    // Wrong key must fail
    wrongKey := make([]byte, 32)
    rand.Read(wrongKey)
    _, err = decrypt(wrongKey, ct1)
    require.Error(t, err, "decryption with wrong key must fail")
}

Summary

  • Include adversarial inputs in validation tests: injection payloads, null bytes, extreme lengths, Unicode edge cases
  • SQL injection tests should verify both that the query fails correctly AND that the underlying data is unmodified
  • Authentication tests must verify that “wrong password” and “unknown email” return identical error messages
  • Authorization tests should try every boundary — owner, other user, admin — and verify the error types
  • Add FuzzXxx tests for any parsing or deserialization function; run with -fuzz= periodically in CI
  • Run govulncheck ./... and gosec ./... in every CI pipeline — low noise, high signal for real vulnerabilities

Resources

Comments

👍 Was this article helpful?