08 · Security Best Practices¶
Security bugs in Go services tend to cluster around a handful of concrete mistakes: unsalted or fast password hashes, timing-leaky comparisons for secrets, and SQL built by string concatenation. This module demonstrates the right and wrong version of each, with real output showing exactly what the wrong version produces.
Password hashing with bcrypt¶
import "golang.org/x/crypto/bcrypt"
func hashPassword(pw string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(hash), err
}
func checkPassword(pw, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
bcrypt.GenerateFromPassword bakes a random salt into the output hash
automatically — never store passwords with sha256.Sum256([]byte(pw))
alone; a fast, unsalted hash lets an attacker with the database precompute
a rainbow table and crack every password at once. bcrypt.DefaultCost
(10) picks a work factor slow enough to make brute-forcing expensive per
guess; raising it further slows down both attackers and your own login
endpoint, so it's a real tradeoff, not a free win.
Constant-time comparison for secrets¶
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
)
func sign(secret, msg []byte) []byte {
mac := hmac.New(sha256.New, secret)
mac.Write(msg)
return mac.Sum(nil)
}
func verify(secret, msg, sig []byte) bool {
expected := sign(secret, msg)
return subtle.ConstantTimeCompare(expected, sig) == 1
}
This is the shape of verifying a webhook signature (Stripe, GitHub, etc.)
or an API token. subtle.ConstantTimeCompare matters specifically because
a plain bytes.Equal or == short-circuits on the first mismatched
byte — an attacker who can measure response timing precisely enough can
use that to guess a valid signature one byte at a time. subtle's version
always compares every byte regardless of where a mismatch occurs, closing
that side channel.
SQL injection: what NOT to do, and the fix¶
// vulnerable is what NOT to do -- string concatenation into SQL.
func vulnerableQuery(db *sql.DB, username string) (*sql.Rows, error) {
q := "SELECT id FROM users WHERE username = '" + username + "'"
return db.Query(q)
}
// safe uses a parameterized query -- the driver escapes the value.
func safeQuery(db *sql.DB, username string) (*sql.Rows, error) {
return db.Query("SELECT id FROM users WHERE username = ?", username)
}
Feeding a classic injection payload through the vulnerable version shows exactly why it's dangerous:
$ go run .
vulnerable query would be: SELECT id FROM users WHERE username = '' OR '1'='1'
safe query keeps it a literal parameter: ? = ' OR '1'='1
The vulnerable query, once assembled, is
... WHERE username = '' OR '1'='1' — a condition that's always true,
returning every row in the table instead of the intended single user. The
parameterized version never assembles a string at all: ? is a
placeholder the driver binds the value to as data, so ' OR '1'='1 is
compared literally against the username column and (correctly) matches
nothing. This is exactly the placeholder pattern from
Level 3, Module 3 — the security argument
is the other, equally important reason to always use it.
Rate limiting¶
import "golang.org/x/time/rate"
limiter := rate.NewLimiter(2, 1) // 2 events/sec, burst of 1
allowed := 0
for i := 0; i < 5; i++ {
if limiter.Allow() {
allowed++
}
}
fmt.Println("requests allowed immediately out of 5:", allowed)
With a burst size of 1, only the very first of five back-to-back calls to
Allow() succeeds — the other four are refused instantly because no time
has passed to refill the token bucket at the configured 2-per-second rate.
Wrapped in HTTP middleware (if !limiter.Allow() { http.Error(w, ...,
http.StatusTooManyRequests); return }), this is the standard way to cap
abuse of a login or password-reset endpoint without needing an external
rate-limiting proxy.
Go-specific traps¶
crypto/md5andcrypto/sha1are still in the standard library and still get reached for out of habit — both are cryptographically broken for anything security-sensitive (password hashing, signatures); usebcrypt/argon2for passwords andsha256/sha512for everything else that needs a real hash.math/randis not cryptographically secure — a token, session ID, or API key generated withmath/randis guessable given enough samples; usecrypto/randfor anything that needs unpredictability an attacker can't reconstruct.- String concatenation into SQL is not "usually fine for internal tools" — the moment any part of the string touches user input, including indirectly (a value from another service, a config file an attacker might influence), it's a real vulnerability regardless of how "internal" the tool is.
bcrypt.GenerateFromPasswordhas a 72-byte input limit — silently truncating longer passwords rather than erroring in older versions of the library; hash a pre-image (e.g.sha256.Sum256([]byte(pw))) first if you need to support arbitrarily long passphrases safely.- A
rate.Limiteris per-process, in-memory — it does not coordinate across multiple replicas of a service behind a load balancer; a distributed system needs a shared store (Redis, etc.) for a rate limit that holds across instances.
How It Actually Works¶
crypto/subtle.ConstantTimeCompare exists because a naive byte-by-byte ==
comparison of a secret (like an HMAC signature) returns as soon as it finds a
mismatching byte — an attacker measuring response timing can use that early exit to
guess a secret one byte at a time (a timing side channel). The constant-time
version always compares every byte regardless of where a mismatch occurs, using
bitwise OR-accumulation instead of early-return branching, so the execution time
carries no information about where the strings differ. TLS termination works by
the crypto/tls package performing a handshake (certificate verification via chain-
of-trust to a root CA, then an ephemeral key exchange like ECDHE) before any
application data is exchanged, after which all reads/writes on that net.Conn are
transparently encrypted/decrypted by the TLS layer — your handler code never sees
raw ciphertext, net/http's Server.ServeTLS just swaps in a tls.Listener
wrapping the ordinary TCP listener. Input validation matters at the boundary
precisely because Go's memory safety only prevents memory-corruption bugs, not
logic bugs like SQL injection or path traversal, which operate entirely within
"valid" memory.
Cheat sheet¶
| Need | API |
|---|---|
| Hash a password | bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) |
| Verify a password | bcrypt.CompareHashAndPassword(hash, pw) == nil |
| Sign a message | hmac.New(sha256.New, secret) then .Write/.Sum(nil) |
| Compare secrets safely | subtle.ConstantTimeCompare(a, b) == 1 |
| Cryptographically secure random bytes | crypto/rand.Read(buf) |
| Parameterized SQL | db.Query("... WHERE x = ?", val) — never string-concatenate |
| Per-endpoint rate limiting | rate.NewLimiter(eventsPerSecond, burst), .Allow() |
Related lessons¶
- Parameterized queries and
database/sqlfundamentals: Level 3, Module 3. - Middleware structure a rate limiter or auth check plugs into: Level 3, Module 2 and Module 4.
Exercise¶
Add HTTP middleware wrapping a /login handler with a rate.Limiter keyed
per-IP (a map[string]*rate.Limiter guarded by a mutex, or sync.Map from
Module 1) so each client IP gets its own
budget instead of one global limiter starving every user. Then write a
/webhook handler that reads an X-Signature header, computes the
expected HMAC over the raw request body, and rejects the request with 401
using subtle.ConstantTimeCompare — confirm with curl that a tampered
body (same signature, different payload) is correctly rejected.