// feedback Sign in to leave feedback about this page. Sign In →
all topics

API

Identity Management: API Authorization

Authorizing machine clients instead of browsers: hashed API keys, scoping, per-key rate limiting, and rotation.

TL;DR

Everything 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-api-keys.sql
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:

●●● includes/require-api-key.php
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];
}
●●● top of an API endpoint
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.

Five-stage pipeline: Authorization Header (Bearer key parsed) leads to Hash and Lookup (sha256, SELECT by hash) leads to Revoked Check (revoked_at IS NULL) leads to Scope Check (read vs write) leads to Handler Runs (last_used_at updated). A dashed reject arrow drops from each of the first four stages to a shared bar reading: reject, 401 or 403, a generic body, exit — nothing after the failed check ever runs.
Structurally the same shape as Part 2's login pipeline — a header and a hash stand in for a cookie and a session.

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:

  1. Generate a new key (B) while the old one (A) is still active.
  2. 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.
  3. Watch A's last_used_at stop advancing.
  4. 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.

Five-stage timeline: Key A Issued (active, in use) leads to Key B Issued (both keys valid, overlap window begins) leads to Traffic Migrated, drawn as an out-of-band dashed step (callers switched to B, outside this app, often the slow step) leads to A's Last Use Checked (last_used_at confirms A has gone quiet) leads to Key A Revoked (revoked_at set, only B accepted now).
Revoking one key never invalidates the others — rotation is issue, migrate, revoke.

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
top