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/randinstead ofcrypto/randfor 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
FuzzXxxtests for any parsing or deserialization function; run with-fuzz=periodically in CI - Run
govulncheck ./...andgosec ./...in every CI pipeline — low noise, high signal for real vulnerabilities
Comments