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
Key format:
ppk_version_userId_nonce_signature
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 };
}
Caching
After successful validation, the user is cached:
export const apiKeyCache = new LRUCache<number, CachedUserAuth>({
max: 10000,
ttl: 5 * 60 * 1000,
});
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:
Top comments (0)