API keys

Long-lived tokens for scripts, CI pipelines, and third-party integrations. Distinct from JWT (short-lived session) and from verification tokens (one-use).

Creating a key

use Derafu\IdentityBundle\Contract\Service\ApiKeyManagerInterface;

$generated = $apiKeyManager->create(
    user: $currentUser,
    name: 'CI Pipeline',
    scopes: ['read:invoices', 'write:invoices'],
    expiresAt: new DateTimeImmutable('+1 year')  // null = never
);

// Show this once — it won't be available again:
$plaintext = $generated->plaintext;  // "dik_a1b2c3d4e5..."

The plaintext starts with dik_ (Derafu Identity Key) + 40 random hex chars. The database stores a SHA-256 hash + a short display prefix (dik_a1b2) for the key-management UI.

Authenticating with a key

The bundle ships an authenticator. Enable it on your API firewall:

security:
    firewalls:
        api:
            stateless: true
            custom_authenticators:
                - Derafu\IdentityBundle\Security\Authenticator\ApiKeyAuthenticator

Clients send the key in the Authorization header:

Authorization: Bearer dik_a1b2c3d4e5f6...

The authenticator only fires when the Bearer value starts with dik_ — no conflict with JWT tokens.

Scopes

Scopes are app-defined strings that restrict what a key can do. The bundle stores and checks them but does not define a catalog.

// Scopes are passed at creation:
$apiKeyManager->create($user, 'Read-only bot', ['read:invoices']);
  • Empty scopes ([]) = unrestricted — the key can do everything the user can.
  • Non-empty scopes = the key can only perform operations that match a listed scope.

Protecting endpoints with scopes

Use the #[RequiresScope] attribute:

use Derafu\IdentityBundle\Attribute\RequiresScope;

#[RequiresScope('write:invoices')]
#[Route('/api/invoices', methods: ['POST'])]
public function createInvoice(): Response { ... }

Multiple #[RequiresScope] are AND-ed — the key must hold every listed scope.

Session users bypass scopes. Scopes restrict what a token can do, not what a user can do. If the request is authenticated via regular session (not an API key), the scope check is skipped entirely.

Programmatic scope check

use Derafu\IdentityBundle\Contract\Service\ApiKeyContextInterface;

$apiKey = $apiKeyContext->getCurrent();

if ($apiKey !== null && !$apiKey->hasScope('write:invoices')) {
    throw new AccessDeniedException('Insufficient scope.');
}

Rate limiting

The bundle includes per-API-key rate limiting with GitHub-style headers. Disabled by default — enable it in your config:

# config/packages/derafu_identity.yaml
derafu_identity:
    rate_limit:
        enabled: true
        default_limit: 1000     # max requests per window
        default_window: 3600    # window size in seconds (1 hour)

Per-scope limits

Different scopes can have different limits. When an API key holds multiple scopes, the most restrictive (lowest limit) matching entry wins. Keys whose scopes don’t match any entry fall back to the default.

derafu_identity:
    rate_limit:
        enabled: true
        default_limit: 1000
        default_window: 3600
        scope_limits:
            'write:invoices':
                limit: 100          # write-heavy keys get 100 req/h
                window: 3600
            'read:invoices':
                limit: 5000         # read-only keys get 5000 req/h
                window: 3600

A key with scopes ['read:invoices', 'write:invoices'] would be limited to 100 req/h (the write:invoices entry is more restrictive). A key with scopes ['manage:users'] has no matching entry — it falls back to default_limit (1000). Unrestricted keys (empty scopes) always use the default.

When enabled, every API-key-authenticated response includes:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1713304800

When the quota is exhausted the listener returns 429 Too Many Requests with a JSON body:

{
    "error": "rate_limit_exceeded",
    "message": "API rate limit exceeded.",
    "retry_after": 1823
}

Session-authenticated users are never rate-limited — this only applies to API key requests.

How it works

The default implementation uses a fixed-window algorithm backed by Symfony’s PSR-6 cache (cache.app). It works out of the box with any cache adapter (filesystem, APCu, Redis, Memcached) — zero extra dependencies.

Switching to Redis for strict atomicity

The PSR-6 implementation is not perfectly atomic under concurrent writes — two simultaneous requests may occasionally both count as the same hit. In practice this means the limit may be exceeded by a handful of requests at most, which is acceptable for rate limiting.

For strict enforcement the bundle also ships a RedisApiKeyRateLimiter that uses the atomic Redis INCR command. To activate it, register it in your application’s services.yaml:

services:
    Derafu\IdentityBundle\Service\RedisApiKeyRateLimiter:
        arguments:
            $redis: '@snc_redis.default'
            $defaultLimit: '%derafu_identity_bundle.rate_limit.default_limit%'
            $defaultWindow: '%derafu_identity_bundle.rate_limit.default_window%'
            $scopeLimits: '%derafu_identity_bundle.rate_limit.scope_limits%'

    Derafu\IdentityBundle\Contract\Service\ApiKeyRateLimiterInterface:
        alias: Derafu\IdentityBundle\Service\RedisApiKeyRateLimiter

Custom rate limiter

Implement ApiKeyRateLimiterInterface for completely custom logic (e.g. per-key overrides or a DynamoDB backend) and rebind the alias:

services:
    Derafu\IdentityBundle\Contract\Service\ApiKeyRateLimiterInterface:
        alias: App\Service\MyCustomRateLimiter

Revoking a key

$apiKeyManager->revoke($apiKey);
// Idempotent — revoking an already-revoked key is a no-op.

Key format summary

Part Example Stored in DB
Full plaintext dik_a1b2c3d4e5f6... (44 chars) Never
Display prefix dik_a1b2 (8 chars) prefix column
Hash SHA-256 of plaintext (64 hex) token_hash column
Scopes ['read:invoices'] JSON column

Defining your app’s scope catalog

The bundle is scope-agnostic. Your app defines what scopes exist:

// A simple enum (recommended):
enum ApiScope: string
{
    case READ_INVOICES = 'read:invoices';
    case WRITE_INVOICES = 'write:invoices';
    case MANAGE_CONTRIBUYENTES = 'manage:contribuyentes';
}

// Validate at key-creation time:
$scopes = array_map(fn ($s) => ApiScope::from($s)->value, $requestedScopes);
$apiKeyManager->create($user, $name, $scopes);

The naming convention (namespace:action) is a suggestion, not a requirement. Use whatever makes sense for your API.

On this page

Last updated on 05/05/2026 by Anonymous