Authenticating an API
Bearer tokens compared with hash_equals, hashed token storage, rate limiting, and why basic auth over plain HTTP is no auth.
hash_equals(string $known_string, string $user_string): boolA browser session keeps a user signed in across pages. An API has no pages and usually no session. The client proves who it is on every single request, in a header.
Bearer tokens
The client sends Authorization: Bearer <token>. The token is a long random string, generated with random_bytes(32) and made printable with bin2hex(). Never use rand() or uniqid(): they are predictable. Show the token to the client once, at creation, and store only hash('sha256', $token). A leaked database then contains nothing an attacker can present, exactly as with password hashes.
Comparing without leaking
== on strings stops at the first byte that differs, so a wrong guess with the right first half takes measurably longer to reject. Repeated enough, that difference reveals the token one character at a time. hash_equals($known, $user) compares in constant time instead. Use it for every secret comparison.
Rate limiting
Count requests per token per time window. Over the limit, answer 429 with a Retry-After header. This protects you from a client stuck in a loop and makes guessing credentials impractical.
Basic auth is not encryption
Authorization: Basic carries the username and password as base64. Base64 is an encoding, not a cipher; anyone can reverse it in one function call. Over http:// the credentials travel as readable text to every network in between. HTTPS is what makes any of this private, whichever scheme you choose.
Example
When to use it
- Give a partner read access to your orders endpoint without handing over a user password.
- Let your own mobile app call the API after a user signs in once.
- Revoke one integration's access without affecting any other client.
- Stop a buggy script from sending ten thousand requests a minute at your database.
More examples
Reading the header and finding the client
The whole check, as it runs behind a real web server. Two details earn their place. The 401 carries WWW-Authenticate, which is what the specification asks for and what tells a client which scheme to use. And the error message is the same whether the header was missing or the token was wrong, so the response never confirms that a token exists. Use 401 for "I do not know who you are" and 403 for "I know, and you may not".
<?php
declare(strict_types=1);
/**
* @return array{id: int, name: string}
*/
function authenticate(PDO $pdo): array
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
// "Bearer " is 7 characters. str_starts_with was added in PHP 8.0.
if (!str_starts_with($header, 'Bearer ')) {
header('WWW-Authenticate: Bearer'); // required on a 401
http_response_code(401);
echo json_encode(['error' => ['code' => 'unauthenticated',
'message' => 'Send an Authorization: Bearer header.']], JSON_THROW_ON_ERROR);
exit;
}
$presented = substr($header, 7);
// Look the row up by the HASH, so the plain token never reaches the query.
$stmt = $pdo->prepare(
'SELECT id, name, token_hash FROM api_clients WHERE token_hash = :hash AND revoked_at IS NULL'
);
$stmt->execute(['hash' => hash('sha256', $presented)]);
$client = $stmt->fetch();
// Compare again in constant time. The lookup proves a row exists;
// hash_equals is what keeps the comparison itself quiet.
if ($client === false || !hash_equals($client['token_hash'], hash('sha256', $presented))) {
http_response_code(401);
echo json_encode(['error' => ['code' => 'unauthenticated',
'message' => 'That token is not valid.']], JSON_THROW_ON_ERROR);
exit;
}
return ['id' => (int) $client['id'], 'name' => $client['name']];
}
Issuing a token
This is why every API you have used shows a key once and then only its last four characters. The prefix is a small kindness: it makes a leaked key searchable in a code repository and recognisable in a log. Note the closing comment, because reaching for password_hash on a 32-byte random token is a common and expensive mistake: it is slow by design and gives you nothing extra against a value no human could guess.
<?php
declare(strict_types=1);
/**
* Returns the plain token. This is the only moment it exists in readable
* form, so the caller must show it to the client now.
*/
function issueToken(PDO $pdo, int $clientId): string
{
// random_bytes is cryptographically secure and throws if the system
// cannot provide randomness. 32 bytes becomes 64 hex characters.
$plain = 'sk_' . bin2hex(random_bytes(32));
$stmt = $pdo->prepare(
'INSERT INTO api_tokens (client_id, token_hash, created_at)
VALUES (:client, :hash, NOW())'
);
$stmt->execute([
'client' => $clientId,
'hash' => hash('sha256', $plain),
]);
return $plain;
}
// A token is a random secret, not a password a human types, so sha256 is
// the right tool here. password_hash is for values people choose and
// remember; see the passwords lesson for why the two differ.
Telling the client where it stands
A limit nobody can see looks like a random failure. Returning the remaining count on every response lets a well-behaved client slow itself down before it is blocked. The fixed window shown here is simple but lets a client send twice the limit across a window boundary; a sliding window or token bucket fixes that at the cost of more bookkeeping. Start simple and measure before replacing it.
<?php
declare(strict_types=1);
$limit = 60;
$window = 60;
$now = time();
$bucket = intdiv($now, $window);
// One row per token per window. ON DUPLICATE KEY is MySQL; Postgres uses
// ON CONFLICT ... DO UPDATE. A production setup usually keeps this in Redis.
$stmt = $pdo->prepare(
'INSERT INTO rate_limits (token_id, window_start, hits)
VALUES (:token, :window, 1)
ON DUPLICATE KEY UPDATE hits = hits + 1'
);
$stmt->execute(['token' => $tokenId, 'window' => $bucket]);
$stmt = $pdo->prepare(
'SELECT hits FROM rate_limits WHERE token_id = :token AND window_start = :window'
);
$stmt->execute(['token' => $tokenId, 'window' => $bucket]);
$hits = (int) $stmt->fetchColumn();
$resetsIn = ($bucket + 1) * $window - $now;
// X-RateLimit-* are a widely copied convention, not a standard.
// Retry-After is a real HTTP header and clients do act on it.
header('X-RateLimit-Limit: ' . $limit);
header('X-RateLimit-Remaining: ' . max(0, $limit - $hits));
if ($hits > $limit) {
header('Retry-After: ' . $resetsIn);
http_response_code(429);
echo json_encode(['error' => ['code' => 'rate_limited',
'message' => 'Too many requests. Try again in ' . $resetsIn . ' seconds.']], JSON_THROW_ON_ERROR);
exit;
}
Discussion