How to Add API Keys, Rate Limiting and Metering to a PHP API
Generate long random keys with a recognisable prefix, show each once and store only a hash, then authenticate from the Authorization header. Count requests per key and per IP in a time window, return 429 with Retry-After at the limit, and debit metered usage in the same transaction as the work.
Designing the key
A good API key is long, random, recognisable and never stored in plain text:
$plain = 'sk_' . bin2hex(random_bytes(24)); // shown to the user once
$prefix = substr($plain, 0, 12); // stored for display: "sk_3f9a1c..."
$hash = hash_hmac('sha256', $plain, getenv('API_KEY_PEPPER'));
$pdo->prepare('INSERT INTO api_keys (user_id, name, key_prefix, key_hash, created_at)
VALUES (?, ?, ?, ?, ?)')
->execute([$userId, $name, $prefix, $hash, gmdate('Y-m-d H:i:s')]);
- Prefix: a fixed prefix makes keys easy to spot in logs and lets secret scanners recognise them.
- Entropy: 24 random bytes is far beyond guessing range.
- Hash, not bcrypt: keys are already high-entropy, so a fast deterministic hash is safe and lets you find the key with an index lookup. Slow password hashes would force you to scan every key.
- Pepper: an HMAC with a secret kept in
.envmeans a leaked database alone cannot be used to test candidate keys. Set it before creating production keys.
Authenticating requests
Accept the key in a header, never in the query string, because URLs end up in server logs, browser history and analytics:
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$key = preg_match('/^Bearer\s+(\S+)$/i', $auth, $m) ? $m[1] : '';
$stmt = $pdo->prepare('SELECT k.id, k.user_id FROM api_keys k
JOIN users u ON u.id = k.user_id
WHERE k.key_hash = ? AND k.revoked_at IS NULL
AND u.blocked_at IS NULL');
$stmt->execute([hash_hmac('sha256', $key, getenv('API_KEY_PEPPER'))]);
Behind Nginx and PHP-FPM, the Authorization header can go missing unless you pass it explicitly with fastcgi_param HTTP_AUTHORIZATION $http_authorization;. Return 401 with the same message for missing, wrong and revoked keys. Update a last_used_at column so users can spot unused keys, and let them revoke keys instantly.
Rate limiting without Redis
On a single server, a database table is enough for a fixed-window limiter. The important part is making read, check and increment one atomic step, or parallel requests all see the same count and slip through:
$pdo->exec('BEGIN IMMEDIATE'); // SQLite: take the write lock first
$row = $pdo->prepare('SELECT count, reset_at FROM rate_limits WHERE bucket = ?');
$row->execute([$bucket]);
$r = $row->fetch();
if (!$r || strtotime($r['reset_at'] . ' UTC') <= time()) {
$pdo->prepare('INSERT INTO rate_limits (bucket, count, reset_at) VALUES (?, 1, ?)
ON CONFLICT(bucket) DO UPDATE SET count = 1, reset_at = excluded.reset_at')
->execute([$bucket, gmdate('Y-m-d H:i:s', time() + $window)]);
} elseif ((int) $r['count'] >= $max) {
$pdo->exec('COMMIT');
$retry = strtotime($r['reset_at'] . ' UTC') - time();
header('Retry-After: ' . max(1, $retry));
http_response_code(429);
exit(json_encode(['error' => ['code' => 'rate_limited', 'retry_after' => $retry]]));
} else {
$pdo->prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')->execute([$bucket]);
}
$pdo->exec('COMMIT');
Use two buckets: one per IP (to slow unauthenticated abuse) and one per key (to enforce plan limits). Send X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset so clients can pace themselves. Fixed windows allow a short burst at the boundary; a sliding window or token bucket smooths that, and Redis becomes worthwhile once several servers share the limits. If you sit behind a CDN, restore the real client IP from a trusted header first.
Metering usage with a credit ledger
For usage-based pricing, give each billable action a cost and keep an append-only ledger. Debit atomically and in the same transaction as the work, so a failed write never charges the customer and a charge never happens without the work:
$pdo->beginTransaction();
$debit = $pdo->prepare('UPDATE users SET credits = credits - :c
WHERE id = :u AND credits >= :c');
$debit->execute([':c' => $cost, ':u' => $userId]);
if ($debit->rowCount() !== 1) {
$pdo->rollBack();
http_response_code(402); // Payment Required: top up to continue
exit;
}
create_report($userId, $input); // the actual work
add_ledger_row($userId, 'reports.create', -$cost); // audit trail
$pdo->commit();
Expose a usage endpoint that returns the current balance and plan allowance. The credits and usage billing guide covers topping up balances from Stripe, and handling Stripe webhooks covers granting credits safely.
Errors, versioning and operations
- Version from day one (
/v1/...) so you can change shapes later without breaking clients. - Use one JSON error shape with a machine-readable code, and real status codes: 401, 404, 405 with an
Allowheader, 422, 429. - Never return raw exceptions or stack traces.
- Never log full keys. Log the stored prefix instead.
- Cap active keys per user and serve the API over HTTPS only.
Key lifecycle: naming, rotation and leaks
Keys leak: pasted into a public repository, a support ticket or a screenshot. Design for that day.
- Names: let users label each key ("production server", "Zapier") so they know which one to revoke.
- Several keys per account: rotation then means creating a new key, deploying it, and revoking the old one, with no downtime.
- Instant revocation: mark the row revoked and make the lookup ignore it, so the next request fails. Do not cache key lookups for long.
- Blocked accounts: join on the user and refuse keys belonging to blocked users, so suspending a customer suspends their integrations.
- Changing the pepper: a new pepper invalidates every stored hash. If you must change it, support the old one alongside the new until keys are rotated.
Show the full key exactly once, at creation, with a copy button and a clear warning. If a user loses it, they create a new one; you cannot recover it because you never stored it.
Document and test it like a customer
An API is only as usable as its first five minutes. Give each endpoint a copy-and-paste example:
curl https://api.example.com/v1/me \
-H "Authorization: Bearer sk_your_key"
curl -X POST https://api.example.com/v1/reports \
-H "Authorization: Bearer sk_your_key" \
-H "Content-Type: application/json" \
-d '{"name":"Weekly summary"}'
Then test the unhappy paths as deliberately as the happy ones: no key, a revoked key, another user's resource ID (expect 404, not 403, so you do not confirm it exists), an invalid body, the rate limit, and an empty credit balance. Fire a burst of parallel requests at a metered endpoint and check the final balance matches the number of successful responses. If your API also serves a mobile app, the mobile app backend guide covers per-device tokens.
Sources: OWASP REST Security Cheat Sheet · RFC 6585: 429 Too Many Requests
How Simple Stack handles it
Simple Stack ships a versioned REST API at /v1 (app/rest.php) for the api-product profile, using the key, rate-limit and credit helpers in app/bootstrap.php. API_PRODUCT.md documents every endpoint.
- Keys are ss_ plus 48 hex characters, shown once and stored as an HMAC-SHA256 hash when peppered
- Bearer or X-API-Key auth; revoked keys and blocked users stop working on the next request
- Per-IP and per-key fixed windows stored in SQLite, with X-RateLimit headers and 429s
- POST /v1/notes debits credits in the same transaction as the insert and returns 402 at zero
- One JSON error envelope, and a 405 response that carries an Allow header
- Active keys are capped per user (10 by default) and revocable from the dashboard or admin
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.