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 1The 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.