API
Identity Management: API Authorization
Authorizing machine clients instead of browsers: hashed API keys, scoping, per-key rate limiting, and rotation.
TL;DREverything built in Part 2 and Part 3 assumes a browser: cookies, CSRF tokens, a human who can complete a Face ID prompt. None of that exists when the caller is a script, a cron job, or another server — there's no cookie jar, no page to render a hidden CSRF field into, and no fingerprint reader to prompt. This part builds the other kind of credential: a long-lived secret a machine sends on every request, issued and revoked by a member the same way a passkey is.
Why Not Just Reuse Sessions
A session cookie is short-lived on purpose and tied to one browser's cookie
jar — exactly the properties a script calling this API from a cron job
doesn't want. It needs a credential that survives for months, gets copied
into a config file or an environment variable once, and doesn't depend on
anything browser-shaped. CSRF protection can also be dropped entirely: CSRF
works by exploiting a browser's automatic cookie attachment on
cross-site requests, and a script setting its own
Authorization header isn't doing that.
Choosing a Shape
Three common options, roughly in order of complexity: a bare
API key sent as a header on every request; a
bearer token, which is really the same idea with a
standardized header name (Authorization: Bearer <token>);
and full OAuth 2.0 client-credentials, where a client
exchanges a client ID and secret for a short-lived access token from a
separate token endpoint, then refreshes it periodically. OAuth's extra
machinery earns its cost when tokens need short lifetimes, third-party
apps need delegated (not owned) access, or multiple services need to trust
a shared identity provider. None of that applies here — this is one
site's members authorizing their own scripts — so this build uses a
hashed API key sent as a bearer token: the simplicity of an API key, the
standard header of a bearer token, without OAuth's token-exchange endpoint.
The Schema
One table, keyed to members the same way
passkey_credentials was in Part 3 — a member can hold
more than one key at a time, which turns out to matter for rotation below:
CREATE TABLE api_keys (
key_id INT NOT NULL AUTO_INCREMENT,
member_id INT NOT NULL,
key_hash VARCHAR(64) NOT NULL, -- sha256 hex, never the raw key
label VARCHAR(100),
scope VARCHAR(20) NOT NULL DEFAULT 'read',
created_at DATETIME NOT NULL,
last_used_at DATETIME,
revoked_at DATETIME,
PRIMARY KEY (key_id),
UNIQUE KEY (key_hash)
);
revoked_at is a nullable timestamp rather than a boolean flag
or a DELETE, for the same reason
password_resets.used_at was in Part 2: keeping the row means
last_used_at and created_at stay available for an
audit trail after a key is retired, instead of disappearing with it.
sha256, not bcrypt. Part 2's password_hash
column needs a deliberately slow algorithm because a human
password carries maybe a few dozen bits of real entropy — slow
hashing is what makes guessing every likely password expensive. An API key
here is random_bytes(32): 256 bits, generated by the server,
never chosen by a human. There's nothing to brute-force in the space of
plausible keys, so the hash's only job is making sure a database leak
doesn't hand out working keys — and a fast hash does that fine, the
same reasoning Part 2 already used for password-reset tokens.
Issuing a Key
The raw key exists in full for exactly one response, the moment it's generated — after that, only its hash is ever stored, so there's no way to display it again later, only to revoke it and issue a new one:
$raw_key = bin2hex(random_bytes(32));
$key_hash = hash('sha256', $raw_key);
$hDB->insert_prepared(
'INSERT INTO api_keys (member_id, key_hash, label, scope, created_at)
VALUES (?, ?, ?, ?, NOW())',
'isss',
$_SESSION['member_id'],
$key_hash,
$label,
$scope
);
// one-time flash — read and cleared by the very next page render
$_SESSION['new_api_key'] = $raw_key;
header('Location: /manage-api-keys.php');
A manage-api-keys.php page mirrors Part 3's
manage-passkeys.php: a list of existing keys by
label, scope, and last_used_at, a
form to generate a new one, and a revoke button per row. The only new
piece is that one-time flash — on render, if
$_SESSION['new_api_key'] is set, the page shows it once in a
copy-this-now box, then immediately unsets it, so refreshing the page
never shows it twice:
if (!empty($_SESSION['new_api_key'])) {
// render the copy-this-now box with $_SESSION['new_api_key']
unset($_SESSION['new_api_key']);
}
Validating a Request
The gate for an API endpoint plays the same role
require_role() does for a browser page — but where that
reads a session, this reads a header, hashes it, and looks it up:
function require_api_key(?string $needs = null): array {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(\S+)$/', $header, $m)) {
http_response_code(401);
exit('Missing or malformed Authorization header.');
}
$key_hash = hash('sha256', $m[1]);
$q = 'SELECT key_id, member_id, scope FROM api_keys
WHERE key_hash = ? AND revoked_at IS NULL';
// ... prepare, bind, execute, fetch into $key_id, $member_id, $scope ...
if (!$found) {
http_response_code(401);
exit('Invalid or revoked API key.');
}
if ($needs === 'write' && $scope !== 'write') {
http_response_code(403);
exit('This key does not have write access.');
}
// UPDATE api_keys SET last_used_at = NOW() WHERE key_id = ?
return ['member_id' => $member_id, 'key_id' => $key_id, 'scope' => $scope];
}
require_once __DIR__ . '/includes/require-api-key.php';
$auth = require_api_key('write'); // or null for read-only endpoints
// $auth['member_id'] now plays the same role $_SESSION['member_id'] does
Hashing the presented key before the lookup means the comparison happens
against key_hash, never the raw secret — a slow
timing side-channel on the lookup itself isn't a concern here the way it
was for password sign-in, since there's no dictionary of likely keys to
narrow down; the 256 bits of entropy in a valid key make brute-forcing one
byte at a time no easier than guessing it outright.
Scoping
The scope column is deliberately the smallest thing that
could work: 'read' or 'write', checked with a
single string comparison in require_api_key() above. A member
generating a key for a read-only reporting script has no reason to hand
that script write access, and a leaked read-only key can't be used to
change anything. Real API products often need finer-grained scopes
— per-resource, per-action — but that's an extension of this
same column and this same check, not a different design.
Rate Limiting Per Key
Part 2's rate_limit() function doesn't need to change at all
— only the bucket key does. Where login called
rate_limit('login', $ip), an API endpoint calls it keyed by
the key's hash instead of the caller's IP:
if (!rate_limit('api', $key_hash)) {
http_response_code(429);
exit('Rate limit exceeded.');
}
Keying by the key rather than the IP matters here specifically because a legitimate server-to-server client often calls from a small, fixed set of IPs (or one) shared across every key that client holds — limiting by IP would let one busy key starve every other key running from the same machine. Nothing stops layering an IP-based limit on top for abuse patterns that show up before a valid key is ever presented, the same way Part 2 limits attempts to sign in, not just successful ones.
Rotation
Because UNIQUE KEY (key_hash) constrains keys, not members,
nothing in this schema stops a member from holding two active keys at
once — which is the entire trick to rotating one without downtime.
Revoking a password would lock a member out of everything until they reset
it; revoking one API key never touches the others:
- Generate a new key (B) while the old one (A) is still active.
- Update whatever calls the API to use B. This step happens entirely outside this app — a config change, a redeploy — and is usually the slowest part.
- Watch A's
last_used_atstop advancing. - Revoke A.
No separate "rotation" feature to build — issuing, migrating, and revoking are the same three operations already built above, just used in that order.
Security Notes
The raw key only ever exists in three places: the moment it's generated,
the one response that shows it, and wherever the caller stores it —
never in a log line, never in a URL or query string (a query string ends
up in access logs, browser history, and referrer headers, none of which
this key should ever reach), and never anywhere but the
Authorization header. HTTPS is non-negotiable for the same
reason it is for a session cookie: a bearer credential sent in the clear
is a bearer credential anyone on the network path now has too.
What This Still Doesn't Cover
- OAuth 2.0 flows — authorization code, refresh tokens, and delegated third-party access are a different problem than one member authorizing their own script, and bring real complexity that isn't worth taking on until something here actually needs delegation.
- Fine-grained scopes — two scopes cover read/write; a per-resource permission model is a bigger schema than this series builds.
- Per-key IP allowlisting — pinning a key to a known set of source IPs would shrink the blast radius of a leaked key further, at the cost of breaking the moment a legitimate caller's IP changes.
Checklist
Schema:
[ ] api_keys table, UNIQUE KEY on key_hash
[ ] revoked_at nullable timestamp, not a DELETE — keeps the audit trail
[ ] sha256 for the key hash, not bcrypt — the key has its own entropy, unlike a password
Issuing:
[ ] Raw key shown exactly once, via a one-time session flash
[ ] Only the hash ever persisted — the raw key can't be redisplayed later
[ ] require_role('member') and CSRF on the generate/revoke forms, same as any other account action
Validating:
[ ] Authorization: Bearer header, not a query string or cookie
[ ] Hash the presented key before every lookup
[ ] revoked_at checked on every request, not just at issue time
[ ] Scope checked per endpoint
[ ] rate_limit() reused, keyed by key hash instead of IP
Rotation:
[ ] Multiple active keys per member allowed by design
[ ] last_used_at checked before revoking the old key
[ ] Revoking one key never invalidates the others
Known gaps, addressed on purpose:
[ ] No OAuth 2.0 flows, no fine-grained per-resource scopes, no IP allowlisting