From Go Developer to Security Mindset: What Building APIs Taught Me About Attack Surfaces
There's a moment in every backend project where you stop thinking "does this work" and start thinking "what happens if someone sends this on purpose." That shift is most of what security actually is. It's not a separate skill bolted onto backend development it's the same skill, pointed at adversarial input instead of expected input. Here's what building real APIs in Go exposed me to, and how each piece connects to an actual attack category.
Every input field is an attack surface, not a form field
When you write a handler like this:
func (h *UserHandler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
user := h.service.CreateUser(req.Email, req.Password)
c.JSON(201, user)
}
the instinct as a developer is: does this parse the JSON correctly, does it call the service layer correctly. The security instinct is different: what's the worst thing req.Email and req.Password could contain, and does anything downstream trust them without checking.
This is the core reframe. Every field a client controls query params, headers, JSON bodies, even the Content-Type header itself is something an attacker can set to anything they want, not just the values your frontend would send. Your Go struct tags and binding:"required" validate shape, not safety. email being present and string-typed doesn't mean it isn't '; DROP TABLE users;-- or a 50,000-character string aimed at your logs.
What this maps to in practice:
-
Injection attacks (SQL, command, log injection) come from exactly this gap trusting that validated shape implies safe content. GORM's parameterized queries protect you from SQL injection if you use them correctly
db.Where("email = ?", req.Email)is safe;db.Where(fmt.Sprintf("email = '%s'", req.Email))is not, and nothing in Go's type system stops you from writing the second one. -
Resource exhaustion / DoS comes from not bounding input size an unbounded
[]bytebody or unlimited paginationlimitparam is free ammunition for someone to make your service do expensive work on request.
Authentication and authorization are two different questions
Building JWT + bcrypt auth for FocusGuard-style projects makes you answer "who is this request from" (authentication). It's tempting to stop there. The harder, more commonly-missed question is "is this specific user allowed to do this specific thing to this specific resource" (authorization) and that check has to happen on every single handler that touches user-owned data, not just at login.
func (h *TaskHandler) GetTask(c *gin.Context) {
taskID := c.Param("id")
userID := c.MustGet("userID").(uint) // set by auth middleware
task, err := h.service.GetTaskByID(taskID)
if err != nil {
c.JSON(404, gin.H{"error": "not found"})
return
}
c.JSON(200, task) // BUG: never checked task.UserID == userID
}
This compiles, passes a happy-path test, and is a textbook Insecure Direct Object Reference (IDOR) the single most common vulnerability in real-world APIs, according to years of OWASP data. Any authenticated user can read any other user's task just by incrementing the ID in the URL. The fix is one line
if task.UserID != userID {
c.JSON(404, gin.H{"error": "not found"}) // 404, not 403 — don't confirm the resource exists
return
}
but the point isn't the line, it's that "the user is logged in" tells you nothing about "the user owns this resource," and you have to check the second thing explicitly, everywhere, every time. There's no middleware shortcut for object-level authorization; it's inherently per-resource.
Passwords, tokens, and the discipline of not inventing your own crypto
Using bcrypt instead of sha256 for password hashing isn't a style preference bcrypt (and argon2) are deliberately slow and tunable, specifically to make brute-forcing leaked hashes computationally expensive. A fast hash function like SHA-256 is wrong for passwords precisely because it's fast: an attacker with a leaked hash database can try billions of guesses a second on commodity GPUs. That's the whole lesson of password hashing in one sentence: the property you want (slow, memory-hard) is the opposite of what makes a hash function good for everything else.
JWTs carry a parallel lesson. It's common to see:
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, _ := token.SignedString([]byte(secret))
with the secret hardcoded, checked into git, or reused across environments. If that secret leaks, an attacker can forge a valid token for any user, including admins no password needed. The same category of mistake trusting a client-controlled input without verifying the algorithm the server expects is behind the well-known JWT "alg": "none" vulnerability, where servers that don't explicitly pin the expected signing algorithm can be tricked into accepting unsigned tokens. Always specify the algorithm explicitly when parsing, never trust the alg header from the token itself.
Dependencies are code you didn't write but ship anyway
Every go get pulls in a dependency tree you haven't read. This is the same trust problem as user input, just at the supply-chain layer instead of the request layer. The 2021 log4j disaster and the more recent string of compromised npm packages exist because "widely used" got conflated with "safe" popularity isn't an audit.
Concretely, for Go:
-
go list -m allshows your full dependency tree, not just direct imports. -
govulncheck ./...(Go's official vulnerability scanner) checks your actual code paths against the Go vulnerability database it's smart enough to tell you if a vulnerable function in a dependency is even reachable from your code, not just present ingo.mod. - Pin versions in
go.sumand don't blindlygo get -uacross a whole project without reading what changed.
Run govulncheck the same way you'd run go vet as a normal part of your workflow, not a one-time audit before a big release.
Logging is where secrets go to die
A pattern that shows up constantly in real Go services:
log.Printf("login attempt: email=%s password=%s", req.Email, req.Password)
Written for debugging, forgotten, shipped. Now every plaintext password that ever hits that endpoint is sitting in a log file which is usually far less protected than your database, often shipped to a third-party log aggregator, and rarely covered by the same access controls or encryption-at-rest policy as your primary datastore. The fix isn't "remove this one line," it's building the habit of treating request logging as its own attack surface: strip or redact anything that touches passwords, tokens, or PII before it ever reaches log.Printf, structured logging, or an APM tool.
Rate limiting and CORS: the boundary between your service and everyone else's
Two things get treated as afterthoughts on most Go APIs, added right before launch instead of designed in from the start: rate limiting and CORS configuration. Both are boundary decisions they define who's allowed to talk to your service and how much.
Skip rate limiting and a single endpoint say, /login or /reset-password becomes a free brute-force or credential-stuffing target. Nothing stops an attacker from firing ten thousand login attempts a minute against your API if nothing's counting requests per IP or per account:
limiter := rate.NewLimiter(rate.Every(time.Second), 5) // 5 req/sec, per instance
func RateLimitMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatusJSON(429, gin.H{"error": "too many requests"})
return
}
c.Next()
}
}
A single global limiter like this is a starting point, not a finished solution in practice you want it keyed per IP or per account (a map[string]*rate.Limiter behind a mutex, or backed by Redis if you're running more than one instance) so one abusive client doesn't throttle everyone else. The specific implementation matters less than the underlying question: is there any ceiling on how many times an attacker can try something, or is the only limit your database's connection pool falling over.
CORS misconfiguration is the inverse problem instead of too little restriction on requests, it's too little restriction on who's allowed to make them from a browser. gin-contrib/cors set to AllowOrigins: []string{"*"} with credentials enabled is a combination that's outright rejected by browsers for good reason: it would let any website on the internet make authenticated requests to your API using a logged-in user's cookies. Setting the actual allowed origins explicitly your real frontend domains, nothing else is the whole fix, and it's a one-line difference between "reasonably scoped" and "wide open."
Rate limiting, auth checks, and input validation are all the same shape
Step back and notice something: rate limiting, object-level authorization, input validation, and CORS are all answering a version of the same question what's the boundary, and is it actually enforced, or just assumed. A missing rate limit assumes clients will behave. A missing ownership check assumes users will only request their own resources. A missing CORS restriction assumes only your frontend will ever call the API from a browser. None of these assumptions are enforced by anything they're just gaps where the code trusted good behavior instead of checking for it.
This is why security review of your own code doesn't require a separate mental framework from writing the code in the first place. Every time you write a handler, service function, or middleware, there's a one-sentence question worth asking before moving on: what is this trusting, and is that trust actually justified, or just convenient.
The actual mindset shift
Every one of these examples is the same move, applied to a different layer: stop asking "does this work for the input I expect" and start asking "what does this do for the input I didn't expect." A handler that works, an auth check that passes tests, a hashing function that "hashes fine" all of these can be completely correct for the happy path and still be a vulnerability, because correctness and security are answering different questions.
You don't need a separate security curriculum to start doing this. You need to re-read code you already wrote your GORM queries, your JWT middleware, your logging calls and ask, for each one, "what's the worst plausible input, and what does this code do with it." That question, asked consistently, is most of what a security engineer's day-to-day actually is. The Go backend experience isn't a detour from cybersecurity it's the same terrain, just described with different vocabulary.
Top comments (0)