API key authentication is one of the most common mechanisms for securing programmatic access to web services. Unlike session-based auth that requires login flows, API keys provide a simple bearer token that clients include with every request. The challenge lies not in the mechanism’s complexity but in the security engineering around it: how you generate keys, store them, validate them on each request, and handle rotation without breaking existing integrations.
The critical insight is that API keys should never be stored in plaintext. Just like passwords, they must be hashed before persistence. However, unlike passwords where you only need to verify a user-supplied value against the hash, API keys often carry metadata (scopes, expiry, ownership) that must be retrievable. This creates a design tension: you need a way to look up the key record without being able to reconstruct the original key. The standard solution is to split the key into a public prefix (used for lookup) and a secret suffix (hashed for verification).
In a typical SaaS platform, API key authentication underpins service-to-service communication and external integrations. Keys are generated with cryptographically secure random bytes, stored as SHA-256 hashes in PostgreSQL, and validated through middleware that extracts the key from the Authorization header or X-API-Key header. The system supports scoped keys with fine-grained permissions and automatic expiration.
API keys must be generated using a cryptographically secure pseudorandom number generator (CSPRNG), never from predictable sources like timestamps or sequential IDs. The key should contain enough entropy to resist brute-force attacks—at minimum 256 bits (32 bytes), typically encoded as a 44-character base64 string or a 64-character hex string. Many systems add a recognizable prefix (e.g., sk_live_) that makes it easy to identify the key type and detect accidental exposure in logs or version control.
A well-designed API key has two parts: a short public prefix (e.g., first 8 characters) used for database lookup, and the full key which is hashed and stored. The prefix enables efficient queries without exposing the secret. The user sees the full key only once at creation time.
When a client presents an API key, the server extracts the prefix to locate the key record in the database, then hashes the full submitted key and compares it against the stored hash using constant-time comparison. Unlike password hashing which uses intentionally slow algorithms (bcrypt, Argon2), API key hashing typically uses fast cryptographic hashes like SHA-256 because the key already has sufficient entropy—there’s no risk of dictionary attacks against a 256-bit random value.
Some credentials must be recoverable (e.g., third-party API tokens that the server needs to use on behalf of the user). These cannot be hashed—they must be encrypted with a symmetric key using AES-256-GCM or similar authenticated encryption. The encryption key itself should be managed through a Key Management Service (KMS) like AWS KMS, HashiCorp Vault, or environment-variable-injected secrets that are never committed to source control.
Hash API keys that you only need to verify (one-way). Encrypt credentials that you need to retrieve and use (two-way). Never store either in plaintext. The distinction determines your entire storage and rotation strategy.
The complete lifecycle of an API key spans creation, storage, validation, rotation, and revocation. At creation, the server generates a random key, stores the hash, and returns the plaintext key to the user exactly once. From that point on, the server never has access to the original key. During validation, each incoming request extracts the key from headers, hashes it, and performs a constant-time comparison against the stored hash. For rotation, the system supports overlapping validity windows—the old key remains valid for a grace period after a new key is created, preventing service interruption.
Authorization: Bearer sk_live_abc123... headercrypto/subtle.ConstantTimeComparepackage apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
const (
KeyLength = 32 // 256 bits
PrefixLen = 8
KeyPrefix = "sk_live_"
)
type APIKey struct {
ID string
Prefix string // first 8 chars of the random part, for DB lookup
Hash string // SHA-256 hex of the full key
Scopes []string
ExpiresAt time.Time
CreatedAt time.Time
OwnerID string
}
// Generate creates a new API key and returns the plaintext (shown once)
// along with the storable record.
func Generate(ownerID string, scopes []string, ttl time.Duration) (plaintext string, record APIKey, err error) {
raw := make([]byte, KeyLength)
if _, err := rand.Read(raw); err != nil {
return "", APIKey{}, fmt.Errorf("csprng failed: %w", err)
}
secret := hex.EncodeToString(raw)
plaintext = KeyPrefix + secret
hash := sha256.Sum256([]byte(plaintext))
record = APIKey{
Prefix: secret[:PrefixLen],
Hash: hex.EncodeToString(hash[:]),
Scopes: scopes,
ExpiresAt: time.Now().Add(ttl),
CreatedAt: time.Now(),
OwnerID: ownerID,
}
return plaintext, record, nil
}
package apikey
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"strings"
"time"
)
var (
ErrInvalidKey = errors.New("invalid API key")
ErrExpiredKey = errors.New("API key has expired")
ErrNoScope = errors.New("insufficient scope")
)
type Store interface {
FindByPrefix(prefix string) (*APIKey, error)
}
func Validate(key string, requiredScope string, store Store) (*APIKey, error) {
if !strings.HasPrefix(key, KeyPrefix) {
return nil, ErrInvalidKey
}
secret := strings.TrimPrefix(key, KeyPrefix)
if len(secret) < PrefixLen {
return nil, ErrInvalidKey
}
prefix := secret[:PrefixLen]
record, err := store.FindByPrefix(prefix)
if err != nil {
return nil, ErrInvalidKey // don't leak whether prefix exists
}
// Constant-time comparison prevents timing attacks
hash := sha256.Sum256([]byte(key))
submitted := hex.EncodeToString(hash[:])
if subtle.ConstantTimeCompare([]byte(submitted), []byte(record.Hash)) != 1 {
return nil, ErrInvalidKey
}
if time.Now().After(record.ExpiresAt) {
return nil, ErrExpiredKey
}
if !hasScope(record.Scopes, requiredScope) {
return nil, ErrNoScope
}
return record, nil
}
func hasScope(scopes []string, required string) bool {
for _, s := range scopes {
if s == required || s == "*" {
return true
}
}
return false
}
Always use crypto/subtle.ConstantTimeCompare (Go) or equivalent for hash comparison. Regular string comparison leaks information through timing differences, enabling attackers to reconstruct valid hashes byte-by-byte.
In a typical platform, API key authentication serves multiple use cases. External integrations authenticate with scoped keys that grant access only to specific API endpoints (e.g., read-only data access vs. write operations). Internal services use machine-to-machine keys with elevated scopes but short TTLs, rotated automatically by the deployment pipeline.
The key management layer also handles encrypted storage of third-party credentials. When the platform needs to call external APIs on behalf of users (e.g., LLM providers, payment gateways), those API keys are encrypted at rest using AES-256-GCM with keys derived from the application’s KMS. This ensures that a database breach does not expose downstream service credentials.
A common design implements a rolling rotation model. When a user requests key rotation, the system generates a new key and marks the old key with a 24-hour grace period. During this window, both keys are valid. After the grace period, the old key is automatically revoked by a background cleanup job. This pattern prevents service disruption during key updates.
Never invalidate old keys immediately on rotation. Provide a configurable grace period (typically 24–72 hours) where both old and new keys are accepted, giving clients time to update their configurations without downtime.
crypto/rand with at least 256 bits of entropy.