APIs

JWT and personal Bearer tokens, versioned routes, shared rate limits and local Swagger UI.

First-class APIs use the same request pipeline on workers and per-request SAPIs. The demo and make:auth include JSON login, scoped profile access, token revocation, browser-based personal-token management and interactive Swagger UI. Browser sessions never authenticate Bearer routes.

Start here: your first endpoint

These commands run inside an existing phponwheels project. If you have not created one, follow the start guide first. For the smallest working example, generate Ping --public, migrate and restart; it needs no login or SMTP.

For a new endpoint, use the CLI. It writes the JSON controller and an editable declaration, then loads that declaration automatically at boot. No route registration needs to be pasted.

Shell

# Protected endpoint: GET /api/v1/products

php pow make:api Products

# Public endpoint: GET /api/v1/ping

php pow make:api Ping --public

# POST /api/v2/create-product

php pow make:api CreateProduct --method=POST --version=2

php pow migrate
# Restart your server, then visit /api/docs.

make:api Products creates app/src/Controllers/Api/V1/ProductsController.php and app/api/v1/products.php. Edit the controller to return your data and the declaration to describe the operation in OpenAPI. The initial response is {"message":"It works"}. Names use StudlyCase and may be nested, such as Admin/Report. Supported methods are GET, POST, PUT, PATCH and DELETE; versions range from 1 to 999. Existing files are never overwritten.

Change the response and its documentation together

For the public Ping example, replace the return statement in app/src/Controllers/Api/V1/PingController.php with:

PHP

return Response::json(['pong' => true]);

In app/api/v1/ping.php, change the operation's summary to Check API availability. Replace the success schema's properties entry with:

PHP

'properties' => ['pong' => ['type' => 'boolean']],

Keep the surrounding declaration and authenticated: false for this public test. Restart the server and open /api/v1/ping: the result is {"pong":true}. At /api/docs, the operation now describes that response; /api/openapi.json contains the boolean property. Swagger descriptions are not runtime validators: controllers still need to validate any request data themselves.

Add an authenticated endpoint

Once you have completed the generated-auth setup, run php pow make:api Products without --public and restart. Calling /api/v1/products without an Authorization header returns 401. Signing into the browser alone does not change that: API routes require a Bearer token.

While signed in, visit /account/tokens and create a personal token, confirming your password and MFA when enabled. Copy the token to Swagger's Authorize dialog, then execute the Products operation. Do not put a token in a URL or screenshot; its value is shown only once. You can also obtain a short-lived JWT from the JSON login example below. These options share the same account checks and revocation rules.

Bearer protection is the default. In a blank project, run php pow make:auth once to install account/token authentication, or supply your own Tokens service and dependencies. A public endpoint needs no account setup. All generated endpoints default to sixty requests per minute, per user and operation (a shared anonymous bucket for public endpoints). The command installs the API migration and local Swagger assets when needed; run migrations before making requests. Adding authentication later reuses the existing API schema.

Blueprint::loadRoutes() automatically discovers adjacent api/vN/*.php declarations when loading app/routes.php. Each file returns a closure taking VersionedRoutes. Files are loaded once at boot, not during requests. The CLI route list, admin route list and the demo/generated-auth OpenAPI document include these endpoints too. Projects using a custom routing bootstrap can explicitly call RouteFiles::register($routes, $root . '/app/api'). Custom documentation handlers can merge declarations using RouteFiles::document($directory, $existingDocument).

Shell

# In this repository's demo:

composer install
php pow migrate
php pow db:seed
php pow serve

# Or, inside a new blank project:

php pow make:auth
# Configure SMTP and APP_URL.

php pow migrate
php pow serve
# Register and verify your account on the running application.

The demo uses the existing seeded account. Generated authentication uses your verified account. Visit /api/docs for Swagger UI, /api/openapi.json for the OpenAPI 3.0.3 document, and /account/tokens while signed in to create or revoke personal tokens. Run migrations before calling token or rate-limit endpoints. Restart workers after application or configuration changes.

Shell

curl -X POST http://127.0.0.1:8080/api/v1/token \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.test","password":"your-password","code":"123456"}'

curl http://127.0.0.1:8080/api/v1/me \
  -H 'Authorization: Bearer YOUR_TOKEN'

curl -X DELETE http://127.0.0.1:8080/api/v1/token \
  -H 'Authorization: Bearer YOUR_TOKEN'

Omit code only when MFA is not enabled. The login endpoint returns access_token, token_type: "Bearer" and expires_in: 900. Incorrect credentials and missing, invalid or replayed MFA codes share a generic 401. API login creates no browser login; reset/magic/OIDC browser flows are not silently exchanged for API tokens.

Tokens and revocation

KindLifetimeUse
JWT access token15 minutes by default, at most one hourShort-lived API sessions issued after password and optional MFA.
Personal Bearer token30 days by default, at most 90 daysNamed scripts and integrations; random 256-bit secret, shown once.

Both kinds have a SHA-256 token fingerprint in api_tokens; no usable token is stored. JWTs are deliberately not stateless: every request checks expiry, the current user provider, the token record, password hash and security version. Deleting a user, blocking it through its provider, resetting its password or revoking account security invalidates tokens. A token can also be individually revoked immediately. Revocation does not log the browser out or invalidate other tokens.

JWT signing is fixed to HS256 using a purpose-derived key from APP_KEY, distinct from encryption and other signing keys. Issuer, audience, issued-at, expiry, subject, token ID and signature are verified. JWT payloads are readable, not encrypted. Signing-key rotation invalidates existing JWTs: previous application keys are not accepted for JWT verification. Personal tokens are independent of that signing key but remain bound to account security.

.env

APP_URL=https://app.example.com

# Defaults to APP_URL; keep it stable across workers.

API_JWT_ISSUER=https://app.example.com

API_JWT_AUDIENCE=phponwheels-api

Use HTTPS in production and protect APP_KEY. Never put tokens in query strings, cookies, logs, emails or public caches. A compromised token has its scopes until expiry or revocation; MFA is checked at issuance, not prompted on every Bearer request. The demo grants only profile:read. There is no wildcard scope, implicit administrator access, refresh token or OAuth2 authorization server.

Personal-token management

/account/tokens requires a browser login. Creating or revoking tokens additionally requires CSRF protection, the current password and an unused MFA/recovery code when enrolled. The full token appears only in the creation response, under Cache-Control: no-store; it is never flashed into a session. Store it securely before leaving the page. Unexpired-token metadata is listed, capped at 100 entries; records invalidated by account security changes can remain listed until expiry or individual revocation. Expired records are reclaimed during token issuance. Revoke unused records before creating more.

Reusable framework services

Add a migration returning new PhpOnWheels\Api\ApiMigration(). It creates token, rate-window and rate-lock tables; down() is reversible. Authentication also needs the existing SecurityMigration schema and your UserProvider. The generated auth migration already includes both; do not install them twice.

PHP

<?php
use PhpOnWheels\Api\Tokens;
use PhpOnWheels\Api\RateLimiter;
use PhpOnWheels\Crypto\Key;

$tokens = new Tokens(
    $database,
    $users,
    $securityState,
    static fn (): Key => Key::fromString($env->required('APP_KEY')),
    $env->string('API_JWT_ISSUER', $env->string('APP_URL')),
    $env->string('API_JWT_AUDIENCE', 'phponwheels-api'),
);
$limiter = new RateLimiter($database);
// Register both as container singletons at boot.

// Only after checking the user's authentication and MFA policy:
$issued = $tokens->personal($user, 'Build server', ['profile:read'], 86400);
$access = $tokens->jwt($user, ['profile:read']);
$tokens->revoke($user->authIdentifier(), $issued->id);

Tokens is a low-level issuance API, not an authorization decision. Never accept arbitrary client-provided scopes or user identifiers. Reauthenticate before creating tokens. JWT login captures the security version before verifying MFA and supplies it to jwt(..., securityVersion: $version), so a concurrent MFA/security change cannot upgrade an earlier primary-factor check.

Versioned routes and OpenAPI

PHP

<?php
use PhpOnWheels\Api\VersionedRoutes;
use PhpOnWheels\Http\Method;

$api = new VersionedRoutes($app->routes, version: 1, title: 'My application API');
$api->add(
    Method::Get,
    '/profile',
    ProfileController::class,
    'api.v1.profile',
    ['summary' => 'Read a profile', 'responses' => [
        '200' => ['description' => 'Current profile'],
        '401' => ['description' => 'Bearer token required'],
    ]],
    scopes: ['profile:read'],
);
$document = $api->document();
// Return Response::json($document->document()) from your documentation handler.

This registers /api/v1/profile, its name, a Bearer guard, a rate limit and its OpenAPI operation from one declaration. A separate instance with version: 2 registers /api/v2/...; versions coexist explicitly. Unknown versions are not redirected to the latest. Route parameters such as {id:\d+} become OpenAPI {id} path parameters. Declare request/response schemas and additional parameters in the operation array; descriptions are not runtime validators. Use JsonBody or your own typed validation.

Bearer protection is on by default. authenticated: false deliberately opts an operation out, as the login endpoint does. Every versioned operation is CSRF-exempt because it must not rely on browser-cookie authority. Do not mount browser state changes in this collection. Use RequireBearer directly for non-versioned routes if needed; it publishes a request-local Principal containing the user, token ID and scopes. Missing or invalid tokens return 401 with WWW-Authenticate; insufficient scopes return 403.

Rate limiting

The shared database enforces a fixed window beginning on the first attempt. The lock row is written before reading counters, so simultaneous workers cannot pass a stale check. The demo allows 60 requests per minute, per authenticated user and operation, shared across that user's tokens. Public login has a shared ceiling of 30 per minute plus five attempts per normalized email over fifteen minutes, including successful attempts.

Anonymous limits are deliberately global: no untrusted forwarding header is treated as an identity. For public production APIs add trusted proxy/WAF limits, especially before invalid-token requests, and configure appropriate application limits. Account-wide login limits can be used to temporarily deny a known account access, just as other account-wide abuse controls can. The portable lock serializes short counter updates: this is reliable shared limiting, not a high-throughput Redis limiter.

PHP

<?php
$result = $limiter->consume('export:user:' . $userId, limit: 10, windowSeconds: 300);
if (!$result->allowed) {
    // Reject the operation; do not perform it and then check the counter.
}

Versioned middleware returns X-RateLimit-Limit, X-RateLimit-Remaining and Unix-time X-RateLimit-Reset. Exhaustion returns 429 and a positive Retry-After in seconds. Key identities are hashed in storage. Expired buckets are removed on consumption. Storage errors fail explicitly rather than disabling limits. Consumption owns a database transaction; do not call it from an already-open transaction.

JSON errors and input

Register ApiResponses for the /api prefix before other response/error middleware, with a registered Logger. It preserves JSON responses and turns API errors, 404/405 responses and unexpected failures into application/problem+json with type, title and status. Unexpected exception details are logged by class, not exposed to clients, even in debug mode. Non-API pages retain their HTML behavior. Responses are marked no-store.

JsonBody::fromRequest($request) accepts a JSON object with Content-Type: application/json, including charset parameters. It rejects malformed JSON (400), bodies above its default 16 KiB bound (413) and other media types (415). string() validates strings without coercing numbers, arrays or objects (422). This parser bound does not replace SAPI/web-server request-size limits.

Swagger UI without a CDN

The dedicated /api/docs page loads locally vendored Swagger UI 5.34.1 (Apache-2.0) from public/assets/swagger/; its LICENSE and NOTICE are shipped alongside the assets. The release tarball SHA-512 is VSr8fXfI93obYaQicLJbLUKGRxDojj/SfRJDDLCioGTUBj4BK/Nj4eE9jCeCAnJxVLi7iS+08PbiZLyrQKBkJQ==. Only this page opts into JavaScript; the rest of the demo and generated account forms stay script-free.

The OpenAPI document has no remote server URL, the online validator is disabled, outgoing requests stay same-origin, and authorization is not persisted. Click Authorize and paste a token without the Bearer prefix. Try it out sends real requests, including token issuance and revocation. Do not enter production credentials on an untrusted machine. The page has a separate CSP allowing local scripts and Swagger's inline styles only; the site's default CSP is unchanged elsewhere. No OAuth2 redirect page or remote fonts are required.