Secure API Key Management: HMAC-SHA256 with Server-Side Pepper

Feb 23, 2026 3 min read

How we designed an API key system that stays secure even if the database is compromised.

API keys are the front door to your platform. Get authentication wrong, and everything else — your architecture, your features, your scaling strategy — becomes irrelevant. An attacker with valid keys owns your system.

When we built PulseRelay, a real-time data platform for live event timing, we needed an API key system that:

  1. Supports multiple roles with different permissions
  2. Can scope keys to specific data channels
  3. Remains secure even in a full database breach
  4. Validates quickly (keys are checked on every request)

This post walks through our approach: HMAC-SHA256 hashing with a server-side pepper, plus a Redis cache for performance.

Why Not Just Hash with bcrypt?

bcrypt is the gold standard for password hashing. It's intentionally slow, making brute-force attacks expensive. So why not use it for API keys?

API keys are different from passwords:

Aspect Passwords API Keys
Entropy Low (human-memorable) High (machine-generated)
Validation frequency Occasional (login) Constant (every request)
Brute-force risk High (dictionary attacks) Low (random strings)

bcrypt's slowness is a feature for passwords — it makes each guess expensive. But for API keys:

  • Keys are long random strings (32+ bytes of entropy). Brute-forcing is already impractical.
  • Keys are validated on every API request. bcrypt's ~100ms verification time would kill performance.

We need something fast but still secure.

The Approach: HMAC-SHA256 with Pepper

Our solution:

  1. Generate a cryptographically random key
  2. Hash it using HMAC-SHA256 with a server-side secret (the "pepper")
  3. Store only the hash in the database
  4. On validation, hash the provided key and compare

 

import hmac
import hashlib
import secrets

# Server-side pepper (from environment, never stored in database)
PEPPER = os.environ["API_KEY_PEPPER"]

def generate_key() -> tuple[str, str]:
    """Generate a new API key and its hash."""
    # 32 bytes = 256 bits of entropy
    raw_key = secrets.token_urlsafe(32)
    
    # Prefix for identification (not secret)
    full_key = f"prk_{raw_key}"
    
    # HMAC with pepper
    key_hash = hmac.new(
        PEPPER.encode(),
        full_key.encode(),
        hashlib.sha256
    ).hexdigest()
    
    return full_key, key_hash

def verify_key(provided_key: str, stored_hash: str) -> bool:
    """Verify a key against its stored hash."""
    computed_hash = hmac.new(
        PEPPER.encode(),
        provided_key.encode(),
        hashlib.sha256
    ).hexdigest()
    
    # Constant-time comparison to prevent timing attacks
    return hmac.compare_digest(computed_hash, stored_hash)
Why HMAC, Not Plain SHA256?

Plain SHA256 is vulnerable to length extension attacks. HMAC isn't. More importantly, HMAC is 

Why a Pepper?

The pepper is a secret known only to the application server, stored in environment variables (never in the database).

If an attacker gets your database:

  • Without pepper: They have hashes. With enough compute, they could find collisions.
  • With pepper: They have hashes but can't compute valid hashes without the pepper. The hashes are useless.

The pepper transforms a database breach from "catastrophic" to "inconvenient."

The Key Structure

Our keys look like: prk_a1b2c3d4e5f6...

prk_         a1b2c3d4e5f6g7h8i9j0...
└─────┘      └──────────────────────┘
prefix       random (32 bytes, base64url)

The prefix (prk_):

  • Makes keys visually identifiable
  • Helps with log filtering and secret scanning
  • Not secret — it's the same for all keys

The random portion:

  • 32 bytes of secrets.token_urlsafe() output
  • ~256 bits of entropy
  • Computationally infeasible to brute-force

We also store a "public ID" — the first 8 characters of the random portion. This can be safely logged and displayed in the admin UI without exposing the full key.

Database Schema

CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    public_id VARCHAR(12) NOT NULL UNIQUE,
    key_hash VARCHAR(64) NOT NULL,  -- SHA256 hex = 64 chars
    role VARCHAR(20) NOT NULL,       -- admin, publisher, subscriber
    channels TEXT[],                 -- scoped channels (empty = all)
    rate_limit INTEGER NOT NULL DEFAULT 100,
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    expires_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_used_at TIMESTAMPTZ
);

CREATE INDEX idx_api_keys_public_id ON api_keys(public_id);
CREATE INDEX idx_api_keys_active ON api_keys(is_active) WHERE is_active = TRUE;

Note: we don't store the actual key anywhere. The full key is shown exactly once — when created — and never again. If lost, generate a new one.

Validation Flow

Every API request includes a key (in the X-API-Key header). Here's the validation flow:

Request with X-API-Key: prk_abc123...
              │
              ▼
    ┌─────────────────┐
    │  Check Redis    │──── Cache hit ────▶ Return cached Principal
    │  Cache          │
    └────────┬────────┘
             │ Cache miss
             ▼
    ┌─────────────────┐
    │  Extract        │
    │  public_id      │  (first 8 chars after prefix)
    └────────┬────────┘
             │
             ▼
    ┌─────────────────┐
    │  Query DB by    │
    │  public_id      │
    └────────┬────────┘
             │
             ▼
    ┌─────────────────┐
    │  HMAC verify    │──── Failed ────▶ Reject (401)
    │  key vs hash    │
    └────────┬────────┘
             │ Success
             ▼
    ┌─────────────────┐
    │  Check active,  │──── Failed ────▶ Reject (401)
    │  not expired    │
    └────────┬────────┘
             │ Success
             ▼
    ┌─────────────────┐
    │  Cache in Redis │
    │  (TTL: 5 min)   │
    └────────┬────────┘
             │
             ▼
    Return Principal (role, channels, rate_limit)
The Redis Cache

Validating every request against the database would be expensive. We cache validated principals in Redis:

async def validate_key(self, key: str) -> Principal | None:
    """Validate an API key and return the principal."""
    # Check cache first
    cache_key = f"auth:key:{key[:20]}"  # Use prefix, not full key
    cached = await self._redis.get(cache_key)
    if cached:
        return Principal.model_validate_json(cached)
    
    # Extract public_id for database lookup
    public_id = key[4:12]  # Skip "prk_", take 8 chars
    
    # Query database
    key_record = await self._repo.get_by_public_id(public_id)
    if not key_record:
        return None
    
    # Verify HMAC
    if not verify_key(key, key_record.key_hash):
        return None
    
    # Check active and not expired
    if not key_record.is_active:
        return None
    if key_record.expires_at and key_record.expires_at < datetime.utcnow():
        return None
    
    # Build principal
    principal = Principal(
        key_id=key_record.id,
        public_id=key_record.public_id,
        name=key_record.name,
        role=key_record.role,
        channels=key_record.channels,
        rate_limit=key_record.rate_limit,
    )
    
    # Cache for 5 minutes
    await self._redis.set(
        cache_key,
        principal.model_dump_json(),
        ex=300
    )
    
    # Update last_used_at (async, don't block response)
    asyncio.create_task(self._repo.touch(key_record.id))
    
    return principal

The cache has a 5-minute TTL. This means:

  • Key revocation takes up to 5 minutes to propagate
  • For our use case, this is acceptable
  • For stricter requirements, reduce TTL or implement cache invalidation on revocation

Role-Based Permissions

Keys have one of three roles:

Role Can Ingest Events Can Read Events Can Stream (WebSocket) Can Manage Keys
admin ✓ (all channels) ✓ (all channels) ✓ (all channels)
publisher ✓ (scoped) ✓ (scoped)
subscriber ✓ (scoped) ✓ (scoped)

The Principal object includes a helper for permission checks:

class Principal(BaseModel):
    key_id: UUID
    public_id: str
    name: str
    role: Role
    channels: list[str]
    rate_limit: int

    def can_write(self, channel: str) -> bool:
        """Check if principal can write to a channel."""
        if self.role == Role.ADMIN:
            return True
        if self.role != Role.PUBLISHER:
            return False
        return not self.channels or channel in self.channels

    def can_read(self, channel: str) -> bool:
        """Check if principal can read from a channel."""
        if self.role == Role.ADMIN:
            return True
        if self.role not in (Role.PUBLISHER, Role.SUBSCRIBER):
            return False
        return not self.channels or channel in self.channels

Empty channels list means "all channels" — useful for admin keys or publishers that should access everything.

Security Considerations

Timing Attacks

We use hmac.compare_digest() for hash comparison, not ==. String equality comparison can leak information through timing — if the first character matches, comparison takes slightly longer than if it doesn't. compare_digest() runs in constant time regardless of where strings differ.

Key Rotation

The pepper should be rotatable. Our approach:

  • Support multiple peppers (current + previous)
  • Try current pepper first, fall back to previous
  • Gradually re-hash keys with new pepper during validation
  • Remove old pepper once all keys are migrated
Audit Logging

We track last_used_at for every key. This helps identify:

  • Unused keys that should be revoked
  • Anomalous usage patterns
  • Keys that might be compromised (sudden activity spike)
Rate Limiting

Each key has a rate_limit (requests per minute). We enforce this with Redis sliding windows — a separate concern from authentication, but configured per-key.

The Admin Portal

Keys are managed through a web-based admin portal (separate service, never exposed to the public internet). Admins can:

  • Create keys with specific roles and channel scopes
  • Set expiration dates
  • Revoke keys immediately
  • View usage statistics

When a key is created, the full key is displayed exactly once. The admin must copy it immediately — we don't store it and can't retrieve it later.

Key Takeaways

  1. Use HMAC, not plain hashing — HMAC is designed for keyed hashing and isn't vulnerable to length extension attacks.
  2. Add a pepper — A server-side secret transforms database breaches from catastrophic to manageable.
  3. Cache aggressively — Validating every request against the database doesn't scale. Redis cache with short TTL balances performance and security.
  4. Use constant-time comparisonhmac.compare_digest() prevents timing attacks.
  5. Show keys once, never again — If you can retrieve a key, so can an attacker who compromises your admin portal.
  6. Scope permissions tightly — Role-based access with channel scoping means a compromised key has limited blast radius.

This post is part of a series on building PulseRelay, a real-time data platform for live event timing systems. See also: Real-Time WebSocket Streaming with Redis Pub/Sub and HTMX + Jinja2: Admin UIs Without JavaScript Frameworks.