Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Token access to the API

An API has no login form and no session: a client program does not keep cookies. Instead it sends a token with every request — a long random string issued to the user in advance. The application uses the token to work out who is calling. A middleware does the check.

The tokens table

-- database/schema.sql
CREATE TABLE api_tokens (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id INT UNSIGNED NOT NULL,
    token_hash CHAR(64) NOT NULL UNIQUE
);

Like a password, a token is stored as a hash. Issue a token to the first user with a script:

// bin/create-token.php
$app = require __DIR__ . '/../config/bootstrap.php';
 
$token = bin2hex(random_bytes(32));
 
$app->getContainer()->get(PDO::class)
    ->prepare('INSERT INTO api_tokens (user_id, token_hash) VALUES (?, ?)')
    ->execute([(int) $argv[1], hash('sha256', $token)]);
 
echo $token . "\n";
php bin/create-token.php 1

The command prints the token — it is visible only now; the database keeps the hash.

Lookup by token

// src/Repository/TokenRepository.php
final class TokenRepository
{
    public function __construct(private PDO $pdo) {}
 
    public function findUserId(string $token): ?int
    {
        $statement = $this->pdo->prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?');
        $statement->execute([hash('sha256', $token)]);
 
        $userId = $statement->fetchColumn();
 
        return $userId === false ? null : (int) $userId;
    }
}

The middleware

The client sends the token in the Authorization: Bearer token header. The middleware reads the header and either lets the request through or answers with code 401 itself:

// src/Middleware/TokenAuthMiddleware.php
final class TokenAuthMiddleware implements MiddlewareInterface
{
    public function __construct(
        private TokenRepository $tokens,
        private ResponseFactoryInterface $responseFactory,
    ) {}
 
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $header = $request->getHeaderLine('Authorization');
 
        $userId = str_starts_with($header, 'Bearer ')
            ? $this->tokens->findUserId(substr($header, 7))
            : null;
 
        if ($userId === null) {
            return Json::write($this->responseFactory->createResponse(), ['error' => 'Unauthorized'], 401);
        }
 
        return $handler->handle($request->withAttribute('user_id', $userId));
    }
}

The user's number goes into a request attribute — the action learns the author through $request->getAttribute('user_id'), not from the submitted data.

Attaching to routes

Reading stays open, while creating and deleting are closed with a nested group:

// config/routes.php
$app->group('/api/posts', function (RouteCollectorProxy $group) {
    $group->get('', Api\ListPostsAction::class);
    $group->get('/{id:[0-9]+}', Api\ShowPostAction::class);
 
    $group->group('', function (RouteCollectorProxy $protected) {
        $protected->post('', Api\CreatePostAction::class);
        $protected->delete('/{id:[0-9]+}', Api\DeletePostAction::class);
    })->add(TokenAuthMiddleware::class);
});

Checking

curl -i -X DELETE http://localhost:8080/api/posts/1
curl -i -X DELETE http://localhost:8080/api/posts/1 -H "Authorization: Bearer your-token"

The first request returns code 401, the second returns 204.

JWT

Another common kind of token is JWT. It is a string that carries the user's number and an expiry time, sealed with a signature. The application verifies the signature and does not touch the database. The firebase/php-jwt package works with them:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
 
$token = JWT::encode(['sub' => 1, 'exp' => time() + 3600], $secret, 'HS256');
 
$payload = JWT::decode($token, new Key($secret, 'HS256'));

$secret is a long random string from .env. With a wrong signature or an expired token decode() throws an exception. The middleware stays the same; only the way of checking changes. The price of the convenience: an issued JWT cannot be revoked before it expires, whereas a token in a table only needs deleting.

Common mistake

A token in the address — /api/posts?token=... — ends up in web server logs, browser history and the Referer header. Send the token only in the Authorization header and only over HTTPS.

Thanks for reading!

Donate to the site — we work hard for you.