Authenticating an API

Bearer tokens compared with hash_equals, hashed token storage, rate limiting, and why basic auth over plain HTTP is no auth.

Syntaxhash_equals(string $known_string, string $user_string): bool

A 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

Try it yourself
Loading editor…
Press Run to execute the code.

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".

Example · php
<?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.

Example · php
<?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.

Example · php
<?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

  • Be the first to comment on this lesson.