Forem

lamj
lamj

Posted on

How I Validate API Keys Without Hitting the Database on Every Request

Free APIs come with a lot of challenges.

One of the biggest ones is API key validation.

If done poorly, it can lead to:

  • performance bottlenecks
  • unnecessary database load
  • potential security issues

Here’s how I approached this problem.

Authorization and API Key Design

I didn’t want to validate every API key with a database query.

So I made the key self-contained.

Example:

Authorization: PetProjects ppk_v1_1_nonce_signature
Enter fullscreen mode Exit fullscreen mode

Key format:

ppk_version_userId_nonce_signature
Enter fullscreen mode Exit fullscreen mode

Where:

  • version — key version
  • userId — user identifier
  • nonce — random value
  • signature — HMAC signature

Validation Flow

The validation process is split into two steps.

1. Fast Validation (No Database)

First, the key is validated locally:

  • structure check
  • data correctness
  • HMAC signature verification

This allows us to reject invalid or garbage keys without touching the database.


2. User Check

If the key is valid:

  • we extract userId
  • then perform a single database query

Validation Code

function validateApiKey(apiKey: string): ApiKeyPayload | null {
    if (!apiKey.startsWith('ppk_')) return null;

    const parts = apiKey.split('_');
    if (parts.length !== 5) return null;

    const [, version, userIdRaw, nonce, signature] = parts;

    const userId = Number(userIdRaw);
    if (!Number.isInteger(userId)) return null;

    if (!signature || !/^[a-f0-9]{64}$/.test(signature)) return null;

    const payload = `${version}.${userId}.${nonce}`;

    const expectedSignature = crypto.createHmac('sha256', API_KEY_SECRET).update(payload).digest('hex');

    if (signature.length !== expectedSignature.length) return null;

    /**
     * Timing-safe comparison is used here.
     *
     * A regular string comparison (===) can be vulnerable to timing attacks:
     * the comparison stops at the first mismatched character,
     * which may allow an attacker to infer parts of the signature
     * based on response time differences.
     *
     * crypto.timingSafeEqual performs a constant-time comparison,
     * preventing leakage of information about matching characters.
     */
    const isValid = crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'));

    if (!isValid) return null;

    return { version, userId, nonce };
}
Enter fullscreen mode Exit fullscreen mode

Caching

After successful validation, the user is cached:

export const apiKeyCache = new LRUCache<number, CachedUserAuth>({
    max: 10000,
    ttl: 5 * 60 * 1000,
});
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • no database hit on every request
  • reduced latency
  • lower database load

Why TTL = 5 Minutes

The TTL is intentionally short.

If a key leaks:

  • it only works for a limited time
  • then requires revalidation via database

This is a trade-off between performance and security.

Final Thoughts

Don’t validate API keys with a database on every request.

Design them to be verifiable locally.

If you're building a free API, this approach can significantly reduce load while keeping things simple.

Example

I’m using this approach in a free API platform I’m building:

https://pet-projects.io/en/apis

Top comments (0)