---
title: "Developer guide"
manual: "API_KEY"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/maikschneider/api-key:developer@1.0"
source: "Developer/Index.rst"
rendered: "2026-10-27T08:22:54+00:00"
---

# Developer guide {#developer}

APIs, middlewares and authentication services verify keys through one
service, `\MaikSchneider\ApiKey\Authentication\ApiKeyAuthenticatorInterface`.

```php
use MaikSchneider\ApiKey\Authentication\ApiKeyAuthenticatorInterface;

final class MyApiController
{
    public function __construct(
        private readonly ApiKeyAuthenticatorInterface $apiKeyAuthenticator,
    ) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $result = $this->apiKeyAuthenticator->authenticate($request);

        if ($result->isInvalid()) {
            return new JsonResponse(['error' => 'Unauthorized'], 401, [
                'WWW-Authenticate' => 'Bearer realm="api"',
            ]);
        }

        if ($result->isValid()) {
            $apiKey = $result->getApiKey();
            // $apiKey->scopes, $apiKey->frontendUserUid, ...
        }

        // no key: try other authentication or continue anonymously
    }
}
```

## Reading the result in the frontend {#developer-middleware}

In the frontend you don't need to call the service yourself. The
[middleware](https://docs.typo3.org/permalink/maikschneider/api-key:configuration-middleware@1.0) attaches the result to the
request, so middlewares placed after it and controllers read it:

```php
use MaikSchneider\ApiKey\Middleware\ApiKeyAuthentication;

$result = ApiKeyAuthentication::getResult($request);
// or: $request->getAttribute('api_key')
```

## Three outcomes {#developer-outcomes}

`authenticate()` returns an `AuthenticationResult` in one of three
states:

-   **Not present (`isPresent()` is `false`)**

    The request carries no key of this extension. Try other authentication
    mechanisms or treat the request as anonymous. A bearer token without the
    `t3key_` prefix counts as not present, so OAuth2 or JWT tokens reach
    their own authenticator.

-   **Valid (`isValid()`)**

    `getApiKey()` returns the authenticated key.

-   **Invalid (`isInvalid()`)**

    A key was presented but rejected. Answer `401` and **never** fall back to
    anonymous access. `getInvalidReason()` tells why, for logging only;
    send clients the same response for every reason.

### Invalid reasons {#developer-reasons}

| Reason | Cause |
| --- | --- |
| `Malformed` | The `X-API-Key` header or a `t3key_` bearer token is not a well-formed key, e.g. followed by more text or comma-joined with another token |
| `Ambiguous` | Two different keys, a repeated `X-API-Key` header, or a `t3key_` bearer token in one of several `Authorization` headers |
| `KeyInQueryString` | A key appears in the query string. Query strings end up in access logs and `Referer` headers |
| `Unknown` | No key with this hash exists |
| `Revoked` | The key record is hidden or deleted |
| `Expired` | The expiry date has passed |
| `IpNotAllowed` | The client IP is outside the key's allowed ranges |
| `SiteNotAllowed` | The request targets a site the key is not allowed for |
| `UserUnavailable` | The bound frontend or backend user is deleted, disabled or outside its start and end time |
| `Misconfigured` | Scopes, allowed IPs or sites are filled but contain no value, e.g. only commas |

## The key object {#developer-api-key}

`\MaikSchneider\ApiKey\Authentication\ApiKey` never carries the secret
or its hash.

| Property | Type | Description |
| --- | --- | --- |
| `uid` | `int` | Record uid |
| `title` | `string` | Label of the key |
| `identifier` | `string` | Prefix of the key, safe to log |
| `scopes` | `list<string>` | Opaque to this extension; empty means unrestricted |
| `frontendUserUid` | `?int` | Bound `fe_users` record |
| `backendUserUid` | `?int` | Bound `be_users` record |
| `expiresAt` | `?DateTimeImmutable` | Expiry, `null` for none |

The extension does not log users in. Map the bound user to your own
authorization model.

## Restrictions {#developer-restrictions}

The authenticator enforces the restrictions stored with a key, and rejects
the key if one fails:

-   **Expiry**

    The key is invalid from its expiry date on (`Expired`).

-   **Allowed IPs**

    The client address must match one of the key's IP addresses or CIDR
    ranges (`IpNotAllowed`). The address comes from the request's
    `normalizedParams` attribute, which resolves the client behind proxies
    listed in `$GLOBALS['TYPO3_CONF_VARS']['SYS']['reverseProxyIP']`.
    Without that attribute `REMOTE_ADDR` is used.

-   **Sites**

    The request's `site` attribute must be one of the key's sites
    (`SiteNotAllowed`). Call `authenticate()` after the site is
    resolved, e.g. in a middleware placed after
    `typo3/cms-frontend/site`. A request without a site fails a
    site-restricted key.

-   **Bound user**

    A key bound to a frontend or backend user stops working while that user
    is deleted, disabled or outside its start and end time
    (`UserUnavailable`). The creator of a key (`created_by`) is only
    recorded and does not affect authentication.

Scopes are not checked by the authenticator, because only the consumer
knows which scope an endpoint needs:

```php
if (!$apiKey->hasScope('articles:write')) {
    return new JsonResponse(['error' => 'Forbidden'], 403);
}
```

A key without scopes has every scope. Scopes are compared exactly. Scopes,
allowed IPs and sites are separated by line breaks or commas. A record whose
field holds only separators is rejected (`Misconfigured`) instead of being
treated as unrestricted.

## Side effects {#developer-side-effects}

Authenticating a request creates no session, sends no cookie and does not
update the user record. The only write is the key's `last_used_at`, at most
once every five minutes per key. Rejected keys are logged as warnings with the
reason, the identifier and the client IP, never with the key itself.

## Caching {#developer-caching}

A response to an authenticated request depends on the key, so it must not be
shared with other clients. For a valid key, the middleware disables the page
cache for the request through the `frontend.cache.instruction` request
attribute. TYPO3 then neither stores the page nor serves a cached one, and
sends `Cache-Control: private, no-store`.

If you call the authenticator yourself, take care of caching yourself:

-   In the frontend rendering chain, disable the page cache before the page is
    rendered:

    ```php
    $cacheInstruction = $request->getAttribute('frontend.cache.instruction', new CacheInstruction());
    $cacheInstruction->disableCache('EXT:my_extension: Authenticated with an API key');
    $request = $request->withAttribute('frontend.cache.instruction', $cacheInstruction);
    ```
-   Don't let a key-dependent response enter a response cache that other
    clients share, or include the key uid in its cache identifier.
-   Send `Cache-Control: private, no-store` or a `Vary` header that covers
    `X-API-Key` and `Authorization`, so reverse proxies and CDNs don't
    store the response for everyone.
