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.