Skip to content
Security

Securing Go REST APIs: Authentication, Authorization and Common Attack Surfaces

Practical Go API security: authentication vs authorization, JWT and refresh tokens, object-level authorization, SQL injection, CORS, and a production checklist.

GoBackend.dev14 min read
GoSecurityREST APIJWTAuthenticationAuthorization

The problem

Most API security bugs aren't exotic — they're a handler that trusts a user_id from the request body instead of the authenticated token, a query built with string concatenation, or a JWT that never expires because nobody set exp. None of these require a sophisticated attacker to find; they show up the first time someone changes a number in a request and gets back data that isn't theirs.

This isn't a cryptography article. It's a walkthrough of the specific places a Go REST API leaks trust, with the insecure version next to the fixed one for each.

Why it matters

Authentication and authorization are different questions, and conflating them is the single most common mistake:

  • Authentication answers "who is making this request?" — verifying an identity, typically via a password check or a validated token.
  • Authorization answers "is this identity allowed to do this specific thing?" — a question that has to be asked again for every request, because being authenticated says nothing about what you're allowed to touch.

A service that checks authentication and then trusts the request for everything else has no authorization layer at all — it just knows someone is asking, not whether they should get what they asked for.

Authentication: password hashing and JWTs

Never store or compare plaintext passwords. Use bcrypt (or argon2), never MD5/SHA1/SHA256 alone — those are fast hashes designed for throughput, which is exactly the wrong property for password storage (fast means cheap to brute-force):

// Insecure — SHA-256 has no work factor; a GPU can try billions of
// candidates per second against a leaked hash.
hash := sha256.Sum256([]byte(password))
 
// Secure — bcrypt's cost factor makes each guess deliberately expensive.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)

JWTs are a reasonable choice for stateless API authentication, but three details determine whether they're safe:

// Insecure — no expiration means a leaked token is valid forever.
claims := jwt.MapClaims{"sub": userID}
 
// Secure — short-lived access token, and the signing method is checked
// explicitly so a token signed with "none" or a different algorithm than
// expected is rejected outright (a well-known JWT library vulnerability
// class: accepting whatever alg the token claims to use).
claims := jwt.MapClaims{
	"sub": userID,
	"exp": time.Now().Add(15 * time.Minute).Unix(),
	"iat": time.Now().Unix(),
}
 
token, err := jwt.ParseWithClaims(tokenString, &claims, func(t *jwt.Token) (interface{}, error) {
	if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
		return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
	}
	return signingSecret, nil
})

A 15-minute access token means a leaked token has a short blast radius, but it also means the client needs a way to stay logged in — that's what a refresh token is for: a long-lived, opaque token stored server-side (so it can be revoked), exchanged for a new short-lived access token when the old one expires. The access token proves identity on every request; the refresh token only ever talks to the token-issuing endpoint, which narrows where a stolen refresh token can be used.

Authorization: RBAC and object-level checks

Role-based access control answers "can this role perform this action" — straightforward once roles exist. The mistake that actually leaks data in production is different: object-level authorization, or the lack of it.

// Insecure — checks the caller is authenticated, then fetches whatever
// invoice ID was requested, without checking it belongs to them.
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.UserIDFromContext(r.Context())
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	invoice, err := h.invoices.GetByID(r.Context(), chi.URLParam(r, "id"))
	// any authenticated user can read any invoice by guessing/incrementing IDs
	writeJSON(w, http.StatusOK, invoice)
}
 
// Secure — the ownership check is part of the query itself, not a
// separate step that's easy to forget to add.
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
	userID, ok := middleware.UserIDFromContext(r.Context())
	if !ok {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	invoice, err := h.invoices.GetByIDForUser(r.Context(), chi.URLParam(r, "id"), userID)
	if errors.Is(err, ErrNotFound) {
		http.Error(w, "not found", http.StatusNotFound) // not 403 — don't confirm the ID exists
		return
	}
	writeJSON(w, http.StatusOK, invoice)
}

This is the OWASP "broken object level authorization" (BOLA) class, and it is consistently one of the most common serious API vulnerabilities found in production. It's easy to miss because the endpoint works correctly for the user who's supposed to see that data — the bug only shows up when someone tries an ID that isn't theirs.

Returning 404 instead of 403 for a resource that exists but isn't yours avoids confirming the ID is valid to someone probing for one — a small detail, but it's the difference between "no such invoice" and "yes, invoice 4821 exists, you're just not allowed to see it."

Input validation and SQL injection

Go's database/sql and pgx both support parameterized queries — using them correctly makes SQL injection a non-issue, but only if every query actually uses placeholders instead of building SQL with string formatting:

// Insecure — string-formats user input directly into SQL. An email of
// `' OR '1'='1` returns every row in the table.
query := fmt.Sprintf("SELECT id FROM users WHERE email = '%s'", email)
row := db.QueryRow(query)
 
// Secure — the driver sends the query and parameters separately; the
// database never treats `email` as part of the SQL syntax.
row := db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email)

Validate input shape before it reaches business logic — not for security theater, but because a request that's malformed in an unexpected way (a negative quantity, an email with no @, a string where a number was expected) should fail with a clear 422 at the boundary, not propagate three layers deep and fail in a way that's harder to diagnose:

func (in CreateOrderInput) Validate() error {
	if in.Quantity <= 0 {
		return NewValidationError("quantity must be positive")
	}
	if len(in.Items) == 0 {
		return NewValidationError("order must contain at least one item")
	}
	return nil
}

CORS and CSRF

CORS is a browser-enforced restriction on which origins can read a response via JavaScript — it does nothing for server-to-server calls or tools like curl, and misconfiguring it doesn't create a vulnerability by itself so much as remove a layer that would have limited one:

// Insecure — reflects any origin, which defeats the purpose of having
// an allowlist at all.
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
 
// Secure — an explicit allowlist; anything else is rejected.
allowed := map[string]bool{"https://app.example.com": true}
if allowed[origin] {
	w.Header().Set("Access-Control-Allow-Origin", origin)
}

CSRF matters specifically for cookie-authenticated endpoints — a browser automatically attaches cookies to a request regardless of which site triggered it, so a malicious page can make a logged-in user's browser fire a state-changing request. A pure JWT-in-Authorization-header API (nothing in a cookie) sidesteps CSRF entirely, because there's no ambient credential for a browser to attach automatically. If you do use cookies for auth, a CSRF token or SameSite=Strict cookie attribute is required — don't skip this just because JWTs are involved elsewhere in the system.

Rate limiting, security headers, and logging

Authentication endpoints specifically need rate limiting independent of the API's general limits — see API Rate Limiting in Go for the implementation; the point here is which endpoints need it most: login and password-reset are the ones attackers actually automate against.

Set the basic response headers that cost nothing and close off entire classes of browser-side attacks:

w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'none'")

Never log sensitive fields. A structured logger that dumps the whole request body on error will eventually log a password, a card number, or a full JWT — redact known-sensitive fields explicitly before they reach a log line, and don't rely on "we'll remember not to log that" as the actual control.

Secret management

API keys, database passwords, and JWT signing secrets belong in environment variables (or a secrets manager), never in source code — not even in a private repository. A secret committed to git history is compromised the moment it's pushed, regardless of whether the repo is later made private or the commit is reverted.

The GoBackend Starter loads its JWT_SECRET and DATABASE_URL from environment variables via config.Load(), and fails fast at startup if they're missing or the JWT secret is too short — catching a missing or weak secret at boot is far better than discovering it in production traffic.

Production considerations

Rotate signing secrets with a transition window, not by swapping them instantly — issue new tokens with the new secret while still accepting tokens signed with the previous one for the old token's remaining lifetime, so a rotation doesn't invalidate every logged-in session at once.

Object-level checks belong in the query, not just the handler. Pushing WHERE user_id = $1 into the SQL itself (as in the invoice example above) means the database enforces the boundary even if a future refactor forgets the check in application code — the same defense-in-depth principle as a database unique constraint for idempotency.

Common mistakes

  • Trusting an ID from the client for ownership. If a request can name which resource to act on, the server must independently verify the caller owns it — never assume a valid-looking ID means valid access.
  • No token expiration, turning any leaked token into permanent access.
  • Building SQL with fmt.Sprintf anywhere a value comes from a request, even "just this once" for a query that seems too simple to bother parameterizing.
  • Logging full request bodies on error paths without redacting passwords, tokens, or payment details.
  • Reflecting the CORS origin unconditionally instead of checking it against an allowlist.

API security checklist

  • Passwords hashed with bcrypt/argon2, never a fast general-purpose hash
  • JWTs have a short expiration and the signing method is verified explicitly
  • Refresh tokens are stored server-side and revocable
  • Every resource-fetching endpoint checks ownership, not just authentication
  • All SQL uses parameterized queries — no string-built SQL with user input
  • Input is validated at the API boundary before reaching business logic
  • CORS uses an explicit origin allowlist
  • CSRF protection is in place for any cookie-authenticated endpoint
  • Auth endpoints (login, password reset) are rate-limited specifically
  • Basic security headers are set (X-Content-Type-Options, X-Frame-Options, CSP)
  • Logs redact passwords, tokens, and payment details
  • Secrets come from environment variables or a secrets manager — never source code

Summary

API security is mostly discipline applied consistently, not a single hard problem: hash passwords properly, verify identity and ownership on every request, parameterize every query, validate input at the boundary, and keep secrets out of source code. The checklist above catches the mistakes that actually show up in real incidents — object-level authorization gaps and unbounded tokens most of all — more often than anything more exotic.