The leak that changes everything
You build a developer dashboard. Users create projects, run builds, and deploy containers. To let their scripts talk to your platform, you hand out API keys. You generate a random string, save it to a PostgreSQL table, and ship the feature. Six months later, a misconfigured cloud bucket exposes your database dump. Every single API key is now public. Attackers can spin up thousands of containers, drain credits, and delete production databases before your team even sees the monitoring alert.
The problem isn't the database leak. The problem is treating an API key like a password that lives on your server. API keys are credentials. They belong to the client. Your server should only ever store a one-way fingerprint of the key. When a request arrives, you compare the incoming key against that fingerprint. If the database gets stolen, the attacker gets cryptographic digests that cannot be reversed. The actual keys remain safe on the user's machine.
How key generation and validation actually work
Generating a secure API key starts with entropy. You need bytes that are impossible to predict. Go's standard library provides crypto/rand for exactly this purpose. It reads from the operating system's entropy pool, which draws from hardware noise, interrupt timing, and other unpredictable sources. You ask for 32 bytes, which gives you 256 bits of randomness. That is enough entropy to make brute-forcing the key mathematically infeasible.
Raw bytes are hard to read and hard to pass in HTTP headers. You encode them into a string. Base64 is the standard choice because it compresses the binary data into a compact, ASCII-safe format. Hex encoding works too, but it produces longer strings for the same amount of entropy.
Storage requires a different approach. You never save the raw key. You run it through a cryptographic hash function. bcrypt is the default in the Go ecosystem because it does three things automatically. It generates a random salt for every key. It mixes the salt into the hashing process. It uses a configurable work factor to slow down the computation. Slowing down the hash is a feature. It makes brute-force attacks expensive.
Validation is just the reverse of hashing, but without revealing the original value. You take the incoming raw key, feed it to the same hash function along with the stored salt and cost factor, and compare the result. bcrypt handles this comparison in constant time. Constant time means the function takes the same amount of CPU cycles whether the key matches on the first character or the last. This prevents timing attacks, where an attacker measures response latency to guess the key one character at a time.
Minimal generation and hashing
Here is the setup for creating a key and preparing it for the database.
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/bcrypt"
)
// GenerateAPIKey creates 32 random bytes and returns a Base64 string.
func GenerateAPIKey() (string, error) {
bytes := make([]byte, 32) // 32 bytes gives 256 bits of entropy
if _, err := rand.Read(bytes); err != nil {
return "", err // rand.Read only fails if the OS entropy pool is exhausted
}
// Base64 keeps the key compact and safe for HTTP headers
return base64.StdEncoding.EncodeToString(bytes), nil
}
// HashAPIKey prepares the raw key for secure database storage.
func HashAPIKey(key string) ([]byte, error) {
// bcrypt generates a random salt and applies the default work factor
return bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
}
The generation function allocates a fixed-size slice. crypto/rand fills it by calling into the kernel. The error check follows the standard Go pattern. The community accepts the if err != nil boilerplate because it makes the failure path impossible to ignore. You see every error handling site explicitly. Base64 encoding transforms every three bytes into four ASCII characters. Your 32-byte key becomes a 44-character string. That length is standard across the industry. It fits comfortably in HTTP headers without triggering length limits.
Validation and the main loop
Here is how you verify a key and run the full lifecycle.
// ValidateAPIKey checks a raw key against a stored bcrypt hash.
func ValidateAPIKey(key string, storedHash []byte) bool {
// CompareHashAndPassword runs in constant time to prevent timing attacks
err := bcrypt.CompareHashAndPassword(storedHash, []byte(key))
return err == nil
}
func main() {
rawKey, err := GenerateAPIKey()
if err != nil {
panic(err)
}
fmt.Println("Raw key:", rawKey)
hashed, err := HashAPIKey(rawKey)
if err != nil {
panic(err)
}
fmt.Println("Valid:", ValidateAPIKey(rawKey, hashed))
fmt.Println("Invalid:", ValidateAPIKey("wrong-key", hashed))
}
The validation function returns a boolean. Under the hood, bcrypt.CompareHashAndPassword returns nil on success or a mismatch error on failure. Wrapping it in a boolean keeps the API clean. You store the entire byte slice returned by HashAPIKey in your database. That slice already contains the algorithm identifier, the cost factor, the salt, and the final hash. When validation happens, bcrypt parses the stored string, extracts the parameters, hashes the input, and compares the results. The hash is the vault. The key is the visitor.
Walking through the runtime behavior
When rand.Read executes, the Go runtime makes a system call to the operating system's secure random number generator. The call blocks until the kernel provides enough entropy. In practice, modern systems fill 32 bytes instantly. If the call fails, it returns an error. The compiler will reject the program with assignment mismatch: 2 variables but rand.Read returns 1 value if you try to capture the return incorrectly, but the correct pattern is checking the error and returning early.
Hashing with bcrypt is deliberately slow. The default cost factor is 12, which means the function runs 2^12 iterations of the Eksblowfish cipher. On a modern CPU, that takes roughly 200 to 300 milliseconds. You want that delay. It turns a brute-force attempt from a few million guesses per second into a few dozen. When you validate a key, the same delay happens. That is acceptable for authentication endpoints, which typically handle one request per user per second.
Base64 encoding happens in memory without system calls. It is fast and predictable. You can swap it for URL-safe Base64 (base64.URLEncoding) if your keys will live in query parameters, but standard Base64 works fine for Authorization headers. The encoding step adds negligible overhead compared to the database lookup and the bcrypt hash.
Keep the hash slow. Make the lookup fast.
Realistic HTTP handler pattern
Production code rarely calls these functions directly from main. They live behind an HTTP handler that extracts the key from the request, looks up the hash in a database, and returns a status code.
package main
import (
"context"
"net/http"
"golang.org/x/crypto/bcrypt"
)
// Authenticate extracts the API key and validates it against a stored hash.
func Authenticate(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Context always goes first by convention, carrying deadlines and cancellation
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
// Strip the "Bearer " prefix to isolate the raw key
rawKey := authHeader[7:]
if len(rawKey) == 0 {
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return
}
// In production, query your database here to get the stored hash
storedHash := []byte("$2a$12$examplehashplaceholder...") // simulated DB lookup
// Constant-time comparison prevents attackers from measuring response latency
err := bcrypt.CompareHashAndPassword(storedHash, []byte(rawKey))
if err != nil {
http.Error(w, "invalid API key", http.StatusUnauthorized)
return
}
// Key is valid. Proceed to the protected route.
w.WriteHeader(http.StatusOK)
w.Write([]byte("access granted"))
}
The handler follows standard Go web conventions. context.Context is the first parameter, named ctx. It carries request-scoped values, deadlines, and cancellation signals. Functions that accept a context should check ctx.Err() before doing expensive work. Database lookups should happen before validation. If the key doesn't exist in your system, you return early. This avoids running the expensive bcrypt check against a non-existent user. You also want to index your database table on the hash column. Hashes are fixed-length strings, which makes B-tree indexes highly efficient. Querying by hash is fast, and the index keeps your latency predictable under load.
Context carries the deadline. The handler respects it.
Common pitfalls and compiler traps
Developers often reach for math/rand because it is in the standard library and requires no imports. math/rand uses a deterministic seed. If an attacker knows the seed or observes a few outputs, they can predict every future key. The compiler won't stop you from using it. You have to choose crypto/rand deliberately.
Another trap is storing the raw key alongside the hash. Some teams save both to make debugging easier. That defeats the entire security model. If the database leaks, the raw keys are exposed immediately. Keep the raw key out of your schema entirely. If you need to display a masked version to the user, generate a separate display token or show the first and last four characters.
Timing attacks happen when you compare keys using the == operator or bytes.Equal. Those functions short-circuit on the first mismatch. An attacker can send thousands of requests, measure the response time, and deduce the correct prefix. bcrypt.CompareHashAndPassword avoids this by always processing the full string. The compiler will complain with invalid operation: operator == not defined on slices if you try to compare byte slices directly, which actually saves you from this mistake. If you compare strings with ==, the compiler accepts it, but your security breaks. Always use the dedicated comparison function.
Goroutine leaks are rare in this specific flow, but they happen if you spawn a background worker to validate keys and forget to close the channel it reads from. Always provide a cancellation path. Context deadlines handle this automatically. When the request times out, the context cancels, and your handler returns. The goroutine exits cleanly.
API keys are credentials. Treat them like cash. Never log them. Never print them to stdout in production. Mask them in error messages. Trust the hash.
When to use which approach
Use crypto/rand with Base64 encoding when you need standard, URL-safe API keys for REST or GraphQL services. Use bcrypt for validation when you want a battle-tested, constant-time comparison that handles salting automatically. Use argon2 when you need stronger resistance against GPU-based cracking attacks and can afford slightly higher CPU cost. Use JWT tokens when you need stateless authentication that carries user claims and expires automatically. Use OAuth2 delegated tokens when you are building integrations that access third-party services on behalf of your users. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.