I’ve been writing and reviewing Go code for a while now - from building backend services to contributing to open-source projects. One thing I’ve learned the hard way is that insecure code doesn’t always look "wrong" - sometimes it looks completely ordinary.
In this post, I want to share five common patterns I keep seeing in real-world Go codebases that can easily turn into serious security issues. These aren’t exotic bugs — they’re patterns that show up quietly, subtly, and often go unnoticed in PRs, even by experienced teams.
1. Passing Raw Input Directly into Queries
Let’s start with the classic:
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
Even if email is sanitized upstream, or “should never come from user input”, you’ve just opened the door for SQL injection. I’ve seen this exact line in production code. It’s easy to write, easy to miss, and dangerous.
What to do instead:
Use parameterized queries. Always.
With database/sql, it’s:
query := "SELECT * FROM users WHERE email = ?"
row := db.QueryRowContext(ctx, query, email)
Also: be careful even with ORM “raw” queries — a lot of people think gorm.Raw() handles escaping. It doesn’t.
2. Constructing Shell Commands with Input Strings
Another one I keep seeing is dynamic command execution:
cmd := exec.Command("sh", "-c", "run-task "+userInput)
Here’s the problem: if userInput contains a semicolon, pipe, or any shell metacharacters — you’ve got command injection.
Better:
Avoid sh -c unless you really need it. Use exec.Command with explicit args:
cmd := exec.Command("run-task", userInput)
And validate or sanitize the input before it gets near the shell.
3. Overexposing http.Request Data to Internal Layers
This one’s sneakier.
I’ve seen APIs where r.URL.Query() or r.FormValue() are passed directly to service layers, logging systems, or even database access. The assumption is that somewhere down the line, someone will validate it. But that rarely happens.
The result?
Untrusted input ends up buried inside your logic, touching sensitive code.
Fix:
Validate early. Don’t pass raw http.Request data into your internal layers. Parse and validate in the handler/controller layer — not deeper.
4. Leaky Global State and Insecure Defaults
This one is more architectural, but still causes security problems.
Global configuration, shared variables, or init() functions that silently override behavior often lead to unexpected exposure. I've seen cases where a global debug = true flag was left on in production — leaking sensitive logs.
Tip:
Avoid global mutable state unless you really need it. Use dependency injection, context, and controlled configuration structs.
5. Ignoring Error Values — Especially from Critical Calls
We all know Go makes you write if err != nil. And yet, I’ve seen plenty of cases where it's quietly ignored:
token, _ := jwt.Parse(...)
That underscore hides a lot. If parsing fails and you continue anyway, that’s an auth bypass waiting to happen.
Always check your errors, especially in:
- Auth-related code
- Encryption/decryption
- File system and network operations
- Anything parsing untrusted data
It’s better to fail early than to keep going with a broken assumption.
None of these patterns are exotic. That’s what makes them dangerous — they blend in. They’re easy to write, easy to review past, and they compile just fine.
Over time, I realized I was repeating the same feedback in code reviews. That led me to build CodexSentinel, a static analyzer for Go that focuses on real security patterns like these — not just lint rules or formatting.
If you’re working in Go and care about security, give it a try or drop me a message.
And if you’ve seen other common anti-patterns — I’d love to hear them.
Thanks for reading. Stay safe
Top comments (0)