schtzie / laravel-keystone
API key management for Laravel — attach API keys to any Eloquent model, authenticate requests via HMAC SHA-256, cache keys in Redis, and support stancl/tenancy v4 multi-tenant architectures.
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/redis: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- aws/aws-sdk-php: ^3.394
- larastan/larastan: ^3.8.1
- laravel/octane: ^2.0
- laravel/pint: ^1.26.0
- orchestra/testbench: ^9.0|^10.8|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- predis/predis: ^3.6
- stancl/jobpipeline: ^1.3
- stancl/tenancy: ^3.8
Suggests
- predis/predis: Required when using the Redis cache store without the PHP Redis extension (^2.0 | ^3.0).
- stancl/tenancy: Required for multi-tenant support via stancl/tenancy v4 (^4.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-11 02:34:27 UTC
README
Client management for Laravel — attach Clients to any Eloquent model, authenticate requests via HMAC SHA-256 with replay-attack protection and payload signing, cache keys in Redis for zero-database-per-request throughput, enforce per-key rate limits using fixed, sliding-window, or token-bucket strategies, and run natively in single-database or multi-database multi-tenant architectures.
Table of Contents
- Overview
- Requirements
- Installation
- Configuration
- Quick Start
- Core Concepts
- Rate Limiting
- Caching
- Key Lifecycle
- Access Logging
- Analytics
- Events
- REST API Routes
- Multi-Tenancy (stancl/tenancy v4)
- Facade Reference
- Artisan Commands
- Events & Observers
- Testing Your Application
Overview
Laravel Keystone lets any Eloquent model (User, Team, Application, etc.) own one or more API Clients. Incoming HTTP requests are securely authenticated and managed through a robust pipeline:
- Replay Protection: Verifies an
X-Timestampheader to block captured-request replay attacks. - HMAC-SHA256 Auth: Verifies a cryptographically secure signature (
X-API-Signature) using a secret key that is never transmitted. - Payload Signing: Verifies an
X-Body-Hashheader to ensure the request body hasn't been tampered with in transit. - Scope & IP Enforcement: Validates that the key possesses the required route scopes and originates from an allowed IP subnet.
- Rate Limiting: Enforces global or per-key request limits using highly concurrent Redis Token Bucket or Sliding Window strategies.
- Access Logging: Asynchronously logs the authentication result (and rejection reasons) to the database for security auditing and usage analytics.
Authorized keys are aggressively cached (via Redis) to achieve zero-database-per-request throughput. The package integrates transparently with stancl/tenancy v4 for both single-database and multi-database multi-tenant setups.
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | 11.x, 12.x, or 13.x |
| Redis (recommended) | Any version supported by illuminate/redis |
| stancl/tenancy (optional) | ^4.0 |
Note: If you are using the
rediscache driver, you must either install the PhpRedis PHP extension via PECL or install the predis/predis package (composer require predis/predis "^2.0|^3.0").
Installation
composer require schtzie/laravel-keystone
The service provider and Keystone facade are auto-discovered via composer.json.
Publish configuration
php artisan vendor:publish --tag=keystone-config
Publish and run migrations
1. Base Table Migration
For standard applications or multi-database tenancy:
php artisan vendor:publish --tag=keystone-migrations
For single-database tenancy (adds the tenant_id column):
php artisan vendor:publish --tag=keystone-migrations-single-db
2. v2.3.0 Enhancements (IP Filtering, Rate Limits, Grace Periods)
If you are upgrading from an older version, or want to use the new v2.3.0 advanced features, publish the enhancements migration:
php artisan vendor:publish --tag=keystone-migrations-enhancements
3. Access Logging (Optional)
If you plan to use database-backed access logging, publish the logs table migration:
php artisan vendor:publish --tag=keystone-migrations-access-logs
4. Run the Migrations
php artisan migrate
Configuration
After publishing, edit config/keystone.php:
return [ // Database table name 'table' => 'keystoneables', // Prefix prepended to every generated client value 'prefix' => 'ks_', // Byte length of randomly generated key / secret (hex output = length * 2) 'key_length' => 40, // Header the client sends the plain Client ID in 'header' => 'X-Client-Id', // Fallback query parameter (used when header is absent) 'query_param' => 'client', // Header the client sends the HMAC-SHA256 signature in 'signature_header' => 'X-API-Signature', // Fallback query parameter for the HMAC signature 'signature_query_param' => 'signature', // Laravel auth guard to log the key owner into (null = skip) 'guard' => null, // Default scopes assigned to new keys when none are specified 'default_scopes' => [], // Grace period allowing old keys to be used after rotation 'rotation_grace_seconds' => 300, // Prevent replay attacks by requiring a timestamp header 'replay_protection' => [ 'enabled' => false, 'timestamp_header' => 'X-Timestamp', 'window_seconds' => 30, ], // Prevent body tampering by enforcing a payload hash header 'payload_signing' => [ 'header' => 'X-Body-Hash', 'require_on_empty' => true, ], // Select the rate limit strategy (fixed_window, sliding_window, token_bucket) 'rate_limit_strategy' => env('KEYSTONE_RATE_LIMIT_STRATEGY', 'fixed_window'), // Global fallback rate limit (requests per window) 'rate_limit' => 60, // Window size in seconds for the global rate limit 'rate_limit_window_seconds' => 60, // Redis caching configuration for zero-database-query throughput 'cache' => [ 'enabled' => true, 'store' => env('KEYSTONE_CACHE_STORE', 'redis'), 'ttl' => 3600, // seconds (null = no expiry) 'prefix' => 'keystone', 'warm_on_miss' => true, // populate cache on DB hit 'refresh_on_use' => true, // re-warm cache after each auth ], 'tenancy' => [ // 'none' | 'single_db' | 'multi_db' 'mode' => env('KEYSTONE_TENANCY_MODE', 'none'), 'tenant_id_column' => 'tenant_id', 'auto_register_bootstrapper' => true, ], // Number of days to keep revoked keys before physical deletion via prune 'prune_revoked_after_days' => 30, ];
Quick Start
1. Add the trait to your model
// Enables full API key management capabilities for this Eloquent model use Schtzie\Keystone\Traits\HasKeystones; class User extends Model { use HasKeystones; }
2. Generate a Client pair
$user = User::find(1); // Creates a cryptographically secure client ID and signing secret $result = $user->createKeystone('My Mobile App'); // IMPORTANT: Show the secret to the client ONCE. // It is never stored in cleartext and cannot be retrieved again. echo $result['client']; // e.g. ks_a1b2c3d4... (The public identifier) echo $result['secret']; // e.g. f9e8d7c6... (The signing secret)
3. Protect routes
// The 'api.key' middleware handles signature verification, cache lookups, and rate limiting Route::middleware('api.key')->group(function () { Route::get('/profile', [ProfileController::class, 'show']); });
4. Client sends requests
The client must:
- Send the public
clientidentifier in theX-Client-Idheader - Compute the HMAC-SHA256 signature (
hash_hmac('sha256', client, secret)) and send it inX-API-Signature
GET /profile HTTP/1.1 X-Client-Id: ks_a1b2c3d4... X-API-Signature: 9f86d081...
Core Concepts
HasKeystones Trait
Add this trait to any Eloquent model to give it Client management:
// Enables full API key management capabilities for this Eloquent model use Schtzie\Keystone\Traits\HasKeystones; class Team extends Model { use HasKeystones; } class Application extends Model { use HasKeystones; }
The trait is polymorphic — any number of model types can own keys, and they all share the same keystoneables table via the keystoneable_type / keystoneable_id columns.
Creating Clients
// Basic generation — no expiry, no scopes $result = $user->createKeystone('Production Key'); // Generation with specific route access scopes $result = $user->createKeystone('Read-Only Key', ['read']); // Generation with a specific expiration date $result = $user->createKeystone( 'Temporary Key', ['read', 'write'], now()->addDays(30)->toImmutable() ); // Advanced generation with IP restrictions, custom rate limits, and extended metadata $result = $user->createKeystone( 'Production Key', ['read', 'write'], null, [ 'description' => 'Used by the mobile application backend', 'metadata' => ['environment' => 'production', 'team' => 'backend'], 'ip_allowlist' => ['192.168.1.0/24', '10.0.0.5'], 'ip_blocklist' => ['192.168.1.100'], 'rate_limit' => 120, // allows 120 requests per window ] ); // The plain key — give this to the client (stored in DB as-is) $result['client']; // The signing secret — show this to the client ONCE (stored in DB as-is) $result['secret']; // The persisted Keystone Eloquent model record $result['model'];
Supported $options:
When calling createKeystone(), the fourth argument is an array of additional options. The following fields are supported and will be automatically persisted to the database:
description(string): Extended human-readable notes.metadata(array): Arbitrary key/value tags for custom filtering and auditing.ip_allowlist(array): Only requests originating from these IPs or CIDR blocks are allowed.ip_blocklist(array): Requests originating from these IPs or CIDR blocks are rejected.rate_limit(int): A custom per-minute request cap that overrides the global default.
Security note: Both the plain Client and the plain secret are stored in the database. The authentication security comes from the HMAC-SHA256 signature requirement — possessing only the Client is never sufficient to authenticate.
HMAC SHA-256 Authentication
Every authenticated request must include a signature:
signature = hash_hmac('sha256', client, secret)
The middleware recomputes this on the server side and rejects requests where the signatures don't match. hash_equals() is used to prevent timing attacks.
Example client code (PHP):
$client = 'ks_a1b2c3d4...'; $secret = 'f9e8d7c6...'; $signature = hash_hmac('sha256', $client, $secret); Http::withHeaders([ 'X-Client-Id' => $client, 'X-API-Signature' => $signature, ])->get('https://your-app.com/api/profile');
Example client code (JavaScript):
const crypto = require('crypto'); const client = 'ks_a1b2c3d4...'; const secret = 'f9e8d7c6...'; const sig = crypto.createHmac('sha256', secret).update(client).digest('hex'); fetch('/api/profile', { headers: { 'X-Client-Id': client, 'X-API-Signature': sig, }, });
Middleware
Register the middleware on any route or group:
// Protect routes using the standard authentication middleware Route::middleware('api.key')->group(fn () => ...); // Enforce Payload Signing on routes that accept request bodies (e.g. POST/PUT) Route::middleware(['api.key', 'api.key.payload'])->post('/webhook', ...); // Or register globally in bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->append(\Schtzie\Keystone\Http\Middleware\AuthenticateWithKeystone::class); // Optional: enforce payload signing globally // $middleware->append(\Schtzie\Keystone\Http\Middleware\VerifyKeystonePayload::class); })
401 response format:
{ "message": "Unauthorized." }
Scope Enforcement
Pass scope names as middleware parameters. The client's key must have all listed scopes:
// This route requires the key to have the 'read' scope Route::middleware('api.key:read')->get('/items', ...); // This route requires the key to have BOTH 'read' and 'write' scopes Route::middleware('api.key:read,write')->post('/items', ...);
Assign scopes when creating a key:
$result = $user->createKeystone('Admin Key', ['read', 'write', 'delete']);
Missing scope returns:
{ "message": "Insufficient scope." }
IP Filtering (Allowlist & Blocklist)
Restrict API key access by client IP addresses. You can pass exact IPs or CIDR subnet notation in ip_allowlist and ip_blocklist:
// Create a key restricted to a specific IP or subnet range $result = $user->createKeystone( name: 'Internal Webhook Key', scopes: ['read', 'write'], options: [ 'ip_allowlist' => ['192.168.1.0/24', '203.0.113.50'], 'ip_blocklist' => ['192.168.1.99'], ] );
- Allowlist (
ip_allowlist): Only requests originating from matching IP addresses or CIDR ranges are allowed. If the client IP is not in the allowlist, the middleware returns403 Forbidden:{ "message": "IP address not allowed." } - Blocklist (
ip_blocklist): Requests originating from blacklisted IPs or subnets are blocked, returning403 Forbidden:{ "message": "IP address blocked." } - CIDR Subnet Support: Both allowlists and blocklists support CIDR mask notation (e.g.
10.0.0.0/8,192.168.1.0/24,172.16.0.0/12) powered by Symfony'sIpUtils.
Replay-Attack Protection
Enable timestamp-based replay detection to reject requests that are captured and replayed after the fact:
// config/keystone.php 'replay_protection' => [ 'enabled' => true, 'timestamp_header' => 'X-Timestamp', // header the client sends 'window_seconds' => 30, // reject if |now - timestamp| > window ],
The client must include the current Unix timestamp (seconds) in the X-Timestamp header. Requests outside the window are rejected before any cache or database lookup:
Http::withHeaders([ 'X-Client-Id' => $client, 'X-API-Signature' => hash_hmac('sha256', $client, $secret), 'X-Timestamp' => time(), ])->get('/api/resource');
Payload Signing (Body Integrity)
The api.key.payload middleware verifies the request body has not been tampered with in transit. Stack it after api.key (which resolves the secret):
Route::middleware(['api.key', 'api.key.payload'])->group(function () { Route::post('/webhooks/inbound', [WebhookController::class, 'handle']); });
The client computes hash_hmac('sha256', $body, $secret) and sends it in the X-Body-Hash header:
$body = json_encode($payload); Http::withHeaders([ 'X-Client-Id' => $client, 'X-API-Signature' => hash_hmac('sha256', $client, $secret), 'X-Body-Hash' => hash_hmac('sha256', $body, $secret), 'Content-Type' => 'application/json', ])->withBody($body, 'application/json')->post('/api/webhooks/inbound');
A mismatch returns 422 Unprocessable Content. Configure via keystone.payload_signing.*:
'payload_signing' => [ 'header' => 'X-Body-Hash', 'require_on_empty' => false, // skip check for requests with no body (GET/HEAD) ],
Accessing the Authenticated Owner
After successful authentication, the resolved keystoneable owner is available in several ways:
// 1. From the request attributes $owner = $request->attributes->get('keystoneable'); // 2. Resolved out of the IoC container by class name $user = app(User::class); // 3. Via the Keystone facade $client = Keystone::resolve($request); // returns Keystone model $owner = $client->keystoneable; // the polymorphic owner // 4. Via a route model binding helper in the controller public function show(Request $request): JsonResponse { $user = $request->attributes->get('keystoneable'); return response()->json(['name' => $user->name]); }
Architecture & Caching Flow
How It Works
The complete resolution pipeline on every authenticated request:
1. Read Client ID & Signature (Headers or Query Params)
│
▼
2. Check Replay Protection timestamp (fast fail)
│ pass
▼
3. In-memory map lookup (zero latency, cleared on tenant switch)
│ miss
▼
4. Cache lookup (Redis) ─── hit ──► 5. Verify HMAC Signature
│ miss │
▼ │ valid
5. Database query ▼
│ found 6. Verify Scopes
▼ │ pass
6. Write to Cache ▼
│ 7. Check IP Allowlist/Blocklist
▼ │ pass
7. Verify HMAC Signature ▼
│ valid 8. Evaluate Rate Limit Strategy (Redis)
▼ │ allow
8. Verify Scopes ▼
│ pass 9. Dispatch Access Logs (Async)
▼ │
9. Check IP Allowlist/Blocklist ▼
│ pass 10. Authorize Request
▼ │
10. Evaluate Rate Limit ▼
│ allow 11. Response Sent to Client
▼ │
11. Dispatch Access Logs ▼
│ 12. terminate(): re-warm cache TTL
▼
12. Authorize Request
Key lookups are cached (using your configured Laravel cache store) to achieve zero-database-per-request throughput. All rate limiting and cache re-warming happens in terminate() — after the response is already sent to the client, adding zero latency to API responses.
Cache Configuration
// config/keystone.php 'cache' => [ // Setting this to false bypasses the cache entirely (forces a DB query every time) 'enabled' => true, // The Laravel cache store driver to use (we highly recommend 'redis' for performance) 'store' => env('KEYSTONE_CACHE_STORE', 'redis'), // How long (in seconds) the key data should stay in cache before expiring 'ttl' => 3600, // The string prefix added to all cache keys 'prefix' => 'keystone', // Automatically write keys to the cache when they are retrieved from the database 'warm_on_miss' => true, // Automatically reset the TTL timer on the cache entry every time it is successfully used 'refresh_on_use' => true, ],
Cache key format (no tenancy):
keystone:key:{client}
keystone:owner:{ModelClass}:{id}
Cache key format (with tenancy):
keystone:{tenant_id}:key:{client}
keystone:{tenant_id}:owner:{ModelClass}:{id}
Manual Invalidation
use Schtzie\Keystone\Facades\Keystone; use Schtzie\Keystone\Cache\KeystoneKeyCacheRepository; // Use the Facade to instantly evict a key from both the Redis cache AND the in-memory map Keystone::invalidate('ks_a1b2c3d4...'); // Flush all in-memory keys for the current request cycle (automatically called on tenant switch) Keystone::flushResolved(); // For bulk operations, resolve the cache repository directly: $cache = app(KeystoneKeyCacheRepository::class); // Evict multiple keys efficiently in a single Redis pipeline call (v2.3.0) $cache->forgetMany(['ks_abc123...', 'ks_def456...']); // Evict all keys owned by a specific Eloquent model $cache->forgetOwner(User::class, $user->id);
Cache entries are automatically evicted on:
Keystone::updated(e.g. revocation) → theKeystoneServiceProviderEloquent observer handles thisKeystone::deleted$owner->revokeAllKeystones()
Rate Limiting
Rate Limit Strategies
Keystone supports three pluggable rate-limiting strategies, configured via keystone.rate_limit_strategy:
| Strategy | Config value | Description |
|---|---|---|
| Fixed Window | fixed_window (default) |
Standard counter per window; delegates to Laravel's RateLimiter. Zero Redis dependency. |
| Sliding Window | sliding_window |
Redis sorted-set approach; eliminates boundary burst spikes. Falls back to fixed window on non-Redis stores. |
| Token Bucket | token_bucket |
Atomic Lua-scripted bucket on Redis; allows bursting up to capacity while guaranteeing average rate. Falls back to fixed window on non-Redis stores. |
// config/keystone.php 'rate_limit_strategy' => env('KEYSTONE_RATE_LIMIT_STRATEGY', 'fixed_window'),
Per-Key Rate Limits
Set a global fallback limit in config, and override per key when creating:
// config/keystone.php — global fallback 'rate_limit' => 60, // requests per window 'rate_limit_window_seconds' => 60, // window size in seconds // Per-key override at creation time $result = $user->createKeystone('High-Volume Key', [], null, [ 'rate_limit' => 1000, // overrides the global config ]);
Rate Limit Headers
All authenticated responses include standard rate-limit headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
Retry-After: 15 (only present when rate limit is exceeded — 429 response)
Key Lifecycle
Revoking Keys
// Revoke a specific key by model instance $user->revokeKeystone($client); // Revoke by primary key ID $user->revokeKeystone(42); // Revoke all keys for this owner $user->revokeAllKeystones();
Revocation is a soft operation — it sets revoked_at to the current timestamp. The key remains in the database until pruned. Revoked keys are immediately evicted from Redis via the Keystone::updated observer.
Rotating Keys
Creates a new key pair and revokes the old one atomically in a database transaction:
$old = $user->keystones()->first(); $new = $user->rotateKeystone($old); // Old key is revoked, evicted from cache // New key is returned with fresh client + secret echo $new['client']; echo $new['secret'];
Grace Periods
When rotating keys, configure a grace period so consumers can pick up the new credentials without a service interruption:
// config/keystone.php 'rotation_grace_seconds' => 300, // old key valid for 5 more minutes after rotation
During the grace period, the old key remains authenticated but a grace_expires_at timestamp is stamped on its record. keystone:list displays keys in grace period with a Grace Period status label.
Automated Deletion
When an owner model (e.g. User) is deleted, its keystones are automatically managed by the trait:
- Soft Deletes: If your model uses Laravel's
SoftDeletes, all active keystones are automatically revoked (which preserves history) and evicted from the cache. - Hard Deletes: If the model is permanently deleted, all related keystones are completely deleted from the database to prevent orphaned rows, and the cache is immediately evicted.
Pruning Old Keys
The keystone:prune command permanently deletes revoked keys older than your configured retention period (prune_revoked_after_days) and bulk-evicts their Redis entries using high-performance pipelining:
# Delete all revoked keys older than the 30-day default php artisan keystone:prune # Override the retention period to aggressively delete keys revoked over 7 days ago php artisan keystone:prune --days=7 # Simultaneously prune old access logs that are older than 90 days php artisan keystone:prune --access-logs --log-days=90
We recommend scheduling this command to run automatically in your console kernel:
// routes/console.php use Illuminate\Support\Facades\Schedule; // Run the daily cleanup for both revoked keys and old access logs Schedule::command('keystone:prune --access-logs')->daily();
Access Logging
Enable structured access logging to record every authentication event:
// config/keystone.php 'access_log' => [ 'enabled' => true, 'driver' => 'database', // 'database' | 'log' | 'both' 'table' => 'keystone_access_logs', 'channel' => null, // Laravel log channel name (null = default) ],
Publish and run the access log migration:
php artisan vendor:publish --tag=keystone-migrations-access-log php artisan migrate
Events recorded:
| Event | Trigger |
|---|---|
authenticated |
Request passed all checks |
rejected_invalid |
Key not found, revoked, expired, or HMAC mismatch |
rejected_replay |
Timestamp outside the replay-protection window |
rejected_ip |
IP address failed allowlist or blocklist |
rejected_scope |
Key lacked a required scope |
rate_limited |
Key exceeded its rate limit |
Log writes are wrapped in a try/catch — a broken storage backend never interrupts the request lifecycle.
Analytics
Query key usage statistics via the KeystoneAnalytics facade (requires access logging to be enabled):
use Schtzie\Keystone\Facades\KeystoneAnalytics; $key = Keystone::findByKeystone('ks_abc...'); // Day-by-day breakdown grouped by event type (last 7 days) $daily = KeystoneAnalytics::dailyUsage($key, days: 7); // Top 10 most-used keys (authenticated events only) $topKeys = KeystoneAnalytics::topKeys(limit: 10); // Daily totals for all keys owned by a user (last 30 days) $ownerStats = KeystoneAnalytics::ownerUsage($user, days: 30); // Rejection summary grouped by reason (last 7 days) $rejections = KeystoneAnalytics::rejectionSummary($key, days: 7); // Total authenticated requests in the last 30 days $total = KeystoneAnalytics::totalRequests(days: 30);
Events
Keystone dispatches events at every significant lifecycle moment. Listen to them in your EventServiceProvider:
use Schtzie\Keystone\Events\KeystoneAuthenticated; use Schtzie\Keystone\Events\KeystoneAuthFailed; use Schtzie\Keystone\Events\KeystoneCreated; use Schtzie\Keystone\Events\KeystoneRevoked; use Schtzie\Keystone\Events\KeystoneRotated; use Schtzie\Keystone\Events\KeystoneRateLimitExceeded; use Schtzie\Keystone\Events\KeystoneExpired; protected $listen = [ KeystoneAuthenticated::class => [SendUsageMetric::class], KeystoneAuthFailed::class => [AlertOnRepeatedFailures::class], KeystoneRateLimitExceeded::class => [NotifyRateLimitBreach::class], KeystoneCreated::class => [SendWelcomeEmail::class], KeystoneRevoked::class => [AuditKeyRevocation::class], KeystoneRotated::class => [NotifyKeyRotation::class], KeystoneExpired::class => [NotifyKeyExpiry::class], ];
| Event class | Payload |
|---|---|
KeystoneAuthenticated |
$keystone, $request |
KeystoneAuthFailed |
$reason (string), $request |
KeystoneRateLimitExceeded |
$keystone, $request |
KeystoneCreated |
$keystone |
KeystoneRevoked |
$keystone |
KeystoneRotated |
$oldKeystone, $newKeystone |
KeystoneExpired |
$keystone, $request |
REST API Routes
Keystone ships optional ready-made routes for client-side key management. Enable them in config:
// config/keystone.php 'routes' => [ 'enabled' => true, 'prefix' => 'keystones', // accessible at /keystones 'middleware' => ['auth'], // standard Laravel auth guard ],
| Method | Path | Route name | Description |
|---|---|---|---|
GET |
/keystones |
keystones.index |
List active keys for the authenticated user |
POST |
/keystones |
keystones.store |
Create a new key pair |
DELETE |
/keystones/{id} |
keystones.destroy |
Revoke a key |
POST |
/keystones/{id}/rotate |
keystones.rotate |
Rotate a key |
Create key request body:
{
"name": "My Mobile App",
"scopes": ["read", "write"],
"expires_at": "2027-01-01",
"description": "Used by the iOS app"
}
Create key response:
{
"id": 42,
"name": "My Mobile App",
"client": "ks_a1b2c3...",
"secret": "f9e8d7...",
"scopes": ["read", "write"],
"expires_at": "2027-01-01T00:00:00.000000Z",
"created_at": "2026-09-10T09:00:00.000000Z"
}
The
secretis only returned once on creation. Store it immediately.
Multi-Tenancy (stancl/tenancy v4)
Keystone supports three tenancy modes, configured via the KEYSTONE_TENANCY_MODE environment variable.
Mode: none (default)
Standard single-tenant setup. No tenant awareness.
KEYSTONE_TENANCY_MODE=none
// Migration: vendor:publish --tag=keystone-migrations // No changes to your routes or middleware order Route::middleware('api.key')->group(fn () => ...);
Mode: single_db
All tenants share one database. A tenant_id column on keystoneables isolates records. A global scope (TenantScope) automatically appends WHERE tenant_id = ? to every query based on the active tenant context.
KEYSTONE_TENANCY_MODE=single_db
# Use the single_db migration (includes tenant_id column + composite index)
php artisan vendor:publish --tag=keystone-migrations-single-db
php artisan migrate
Route setup — the tenancy identification middleware must run before api.key:
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; Route::middleware([ InitializeTenancyByRequestData::class, // sets tenant() context 'api.key', // then Keystone filters by tenant_id ])->group(fn () => ...);
Creating keys — tenant_id is stamped automatically:
// Tenant context is already initialized by stancl $team = Team::find(1); $result = $team->createKeystone('Team Key'); // The stored client row will have tenant_id = tenant()->getTenantKey()
How isolation works:
| Layer | Mechanism |
|---|---|
| Database | TenantScope global scope → WHERE tenant_id = ? on all Keystone queries |
| Cache | Cache keys are namespaced as keystone:{tenant_id}:key:... |
| In-memory | KeystoneBootstrapper::bootstrap() clears the in-memory resolved map on tenant switch |
Mode: multi_db
Each tenant has its own separate database. stancl/tenancy switches the Eloquent connection automatically. Keystone queries just pick up the active connection — no tenant_id column needed.
KEYSTONE_TENANCY_MODE=multi_db
# Use the standard migration — run it in each tenant's database via stancl php artisan vendor:publish --tag=keystone-migrations php artisan tenants:migrate # stancl/tenancy command
Route setup:
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; Route::middleware([ InitializeTenancyByDomain::class, // switches DB connection + Redis prefix 'api.key', ])->group(fn () => ...);
How isolation works:
| Layer | Mechanism |
|---|---|
| Database | stancl switches the Eloquent DB connection before your routes run |
| Cache | stancl's RedisTenancyBootstrapper (if using Redis) or cache manager handles prefixing; Keystone adds keystone:{tenant_id}: on top |
| In-memory | KeystoneBootstrapper flushes the resolved map on every tenant switch (critical for Octane / queue workers) |
KeystoneBootstrapper
KeystoneBootstrapper is registered automatically when stancl/tenancy is installed and tenancy.auto_register_bootstrapper = true (default). It implements Stancl\Tenancy\Contracts\TenancyBootstrapper and is appended to stancl's bootstrapper stack:
// Auto-registered — no manual config needed // You can disable it and register manually: // config/keystone.php 'tenancy' => [ 'auto_register_bootstrapper' => false, ], // config/tenancy.php 'bootstrappers' => [ ... \Schtzie\Keystone\Tenancy\KeystoneBootstrapper::class, ],
It calls KeystoneService::flushResolved() on both bootstrap() and revert(), ensuring in-memory key state never leaks between tenants in long-lived PHP processes (Octane, queue workers, etc.).
Facade Reference
use Schtzie\Keystone\Facades\Keystone; // Resolve an Keystone from a request (full pipeline: Redis → DB → HMAC verify) $client = Keystone::resolve($request); // Keystone|null // Look up a key by its plain value (no HMAC check) $client = Keystone::findByKeystone('ks_abc...'); // Keystone|null // Generate a key for a model (delegates to $owner->createKeystone()) $result = Keystone::generate($user, 'My Key', [ 'scopes' => ['read'], 'expires_at' => now()->addYear()->toImmutable(), ]); // Evict from both in-memory map and cache Keystone::invalidate('ks_abc...'); // Clear the in-memory resolved map (called automatically on tenant switch) Keystone::flushResolved();
Artisan Commands
| Command | Description |
|---|---|
keystone:generate {model} {id} {name} |
Generate a new key pair for any Eloquent model owner |
keystone:revoke {client} |
Immediately revoke a key by its client ID (with confirmation; --force to skip) |
keystone:list |
List keys with status, owner, scopes, expiry (filterable by --active, --revoked, --owner-type, --owner-id) |
keystone:status |
Display key counts, cache configuration, and access-log statistics |
keystone:rotate-expiring |
Auto-rotate keys expiring within N days (--days=7, --dry-run) |
keystone:prune |
Delete revoked keys older than prune_revoked_after_days and evict their cache entries |
keystone:prune --days=7 |
Override the retention period |
Examples:
# Generate a key for User ID 1 with scopes php artisan keystone:generate "App\Models\User" 1 "CI Bot" --scope=read --scope=write # Revoke a specific key without confirmation prompt php artisan keystone:revoke ks_abc123... --force # List all active keys for a specific owner php artisan keystone:list --owner-type="App\Models\User" --owner-id=1 --active # Rotate all keys expiring in the next 14 days (preview, then run) php artisan keystone:rotate-expiring --days=14 --dry-run php artisan keystone:rotate-expiring --days=14 # Prune old keys and old access logs php artisan keystone:prune --access-logs --log-days=90 # Display system status dashboard php artisan keystone:status
Multi-Database Tenancy Commands
If you are using a multi_db tenancy approach (where each tenant has their own isolated database), you can execute any Keystone Artisan command against a specific tenant's connection using the --tenant= option:
php artisan keystone:list --tenant=acme-corp php artisan keystone:generate "App\Models\User" 1 "Acme Key" --tenant=acme-corp
Because Keystone is agnostic to your tenancy implementation, you must register a callback in your AppServiceProvider to instruct Keystone on how to physically switch the database connection:
use Schtzie\Keystone\Facades\Keystone; public function boot(): void { // Example for stancl/tenancy Keystone::initializeTenantUsing(function (string $tenantId) { tenancy()->initialize($tenantId); }); }
Events & Observers
Keystone provides a comprehensive suite of events you can listen to in your AppServiceProvider to trigger custom logic, security alerts, or webhooks.
Application Events
Event Class (Schtzie\Keystone\Events\*) |
Dispatched When | Properties |
|---|---|---|
KeystoneAuthenticated |
A request is successfully authenticated | $keystone |
KeystoneAuthFailed |
Authentication is rejected (bad signature, expired, scope missing, IP blocked, replay attack) | $client, $reason |
KeystoneRateLimitExceeded |
A request breaches the active rate limit | $keystone |
KeystoneCreated |
A new key pair is generated | $keystone, $plainSecret |
KeystoneRevoked |
An active key is revoked | $keystone |
KeystoneRotated |
A key is rotated (old key revoked, new key generated) | $oldKeystone, $newKeystone, $plainSecret |
Example: Logging Security Alerts
You can easily listen to these events in your AppServiceProvider to trigger external webhooks or log security incidents to exception trackers like Sentry:
use Schtzie\Keystone\Events\KeystoneAuthFailed; use Schtzie\Keystone\Events\KeystoneRateLimitExceeded; use Illuminate\Support\Facades\Event; use Sentry\State\Scope; public function boot(): void { // Log suspicious auth failures to Sentry Event::listen(function (KeystoneAuthFailed $event) { // Only log severe security rejections, ignore general missing headers if (in_array($event->reason, ['invalid_credentials', 'ip_not_allowed', 'ip_blocked'])) { \Sentry\withScope(function (Scope $scope) use ($event) { $scope->setTag('failure_reason', $event->reason); if ($event->client) { $scope->setTag('client_id', $event->client); } \Sentry\captureMessage("Security Alert: API Auth Failed ({$event->reason})"); }); } }); // Log Rate Limit breaches to Sentry Event::listen(function (KeystoneRateLimitExceeded $event) { \Sentry\withScope(function (Scope $scope) use ($event) { $scope->setTag('client_id', $event->keystone->client); \Sentry\captureMessage('API Rate Limit Exceeded'); }); }); }
Eloquent Model Observers
Keystone automatically hooks into its own Eloquent model events to keep the Redis cache perfectly in sync without manual intervention:
| Model Event | Action Taken |
|---|---|
Keystone::updated |
Evicts the key from cache (fires automatically on revoke(), rotate(), etc.) |
Keystone::deleted |
Evicts the key from cache (fires automatically on hard-delete or prune) |
Testing Your Application
Asserting a key was created
// Generate the key $result = $user->createKeystone('Test Key'); // Verify the plain client and name were persisted $this->assertDatabaseHas('keystoneables', [ 'client' => $result['client'], 'name' => 'Test Key', ]);
Asserting events were dispatched
When testing your application's integration with Keystone, you can use Laravel's built-in Event faker to ensure security events are firing correctly:
use Illuminate\Support\Facades\Event; use Schtzie\Keystone\Events\KeystoneAuthFailed; public function test_it_dispatches_failure_event_on_bad_signature(): void { Event::fake(); // Send a request with a deliberately bad signature $this->getJson('/api/protected', [ 'X-Client-Id' => 'ks_valid_client_id', 'X-API-Signature' => 'invalid_signature_string', ])->assertUnauthorized(); // Assert the event was caught and the reason is exactly what we expect Event::assertDispatched(KeystoneAuthFailed::class, function (KeystoneAuthFailed $event) { return $event->reason === 'invalid_credentials'; }); }
If you are using Pest PHP:
use Illuminate\Support\Facades\Event; use Schtzie\Keystone\Events\KeystoneAuthFailed; it('dispatches failure event on bad signature', function () { Event::fake(); $this->getJson('/api/protected', [ 'X-Client-Id' => 'ks_valid_client_id', 'X-API-Signature' => 'invalid_signature_string', ])->assertUnauthorized(); Event::assertDispatched(KeystoneAuthFailed::class, function (KeystoneAuthFailed $event) { return $event->reason === 'invalid_credentials'; }); });
Asserting authenticated requests
// Generate a valid key for the user $result = $user->createKeystone('Test Key'); // Compute the HMAC-SHA256 signature using the secret $sig = hash_hmac('sha256', $result['client'], $result['secret']); // Pass both headers in your feature test $this->getJson('/api/protected', [ 'X-Client-Id' => $result['client'], 'X-API-Signature' => $sig, ])->assertOk();
Testing with scopes
// Create a key that ONLY has the 'read' scope $result = $user->createKeystone('Read-Only', ['read']); $sig = hash_hmac('sha256', $result['client'], $result['secret']); // Attempting to access a route that requires 'write' will fail $this->getJson('/api/write-resource', [ 'X-Client-Id' => $result['client'], 'X-API-Signature' => $sig, ])->assertUnauthorized();
Testing Helpers
Keystone provides several testing utilities to simplify your test suites:
1. KeystoneFake — bypass the real service and mock authentication:
use Schtzie\Keystone\Facades\Keystone; // Swap the real service for a fake that automatically approves EVERYTHING Keystone::fake(); // Swap for a fake that automatically rejects EVERYTHING (simulates 401s) Keystone::fake(null); // Provide a specific Keystone model for the fake to resolve to (simulates a specific user) Keystone::fake($specificKey); // Run your test requests... // Assert that the fake was hit exactly twice Keystone::assertAuthenticatedTimes(2); // Assert that no authentication attempts were made Keystone::assertNotAuthenticated();
2. Request Macros — easily attach headers to a test request:
use Schtzie\Keystone\Testing\KeystoneFactory; $key = KeystoneFactory::new()->create(); // Automatically computes and attaches X-Client-Id and X-API-Signature headers $this->actingWithKeystone($key) ->getJson('/api/protected') ->assertOk(); // Attaches headers and forces specific scopes onto the request $this->actingWithKeystoneScopes($key, ['write']) ->postJson('/api/data') ->assertOk();
3. KeystoneFactory — fluent factory for test keys:
use Schtzie\Keystone\Testing\KeystoneFactory; // Generate keys in various states for testing $expiredKey = KeystoneFactory::new()->expired()->create(); $revokedKey = KeystoneFactory::new()->revoked()->create(); $scopedKey = KeystoneFactory::new()->withScopes(['admin'])->create(); $metaKey = KeystoneFactory::new()->withMetadata(['version' => '1.0'])->create(); // Generate keys with strict security and rate limiting rules $secureKey = KeystoneFactory::new() ->withRateLimit(500) ->withIpAllowlist(['192.168.1.0/24']) ->withIpBlocklist(['10.0.0.15']) ->create('Secure API Key');
Disabling the cache in tests
Add this to your test's defineEnvironment() or in phpunit.xml:
// Force the package to skip the cache and hit the database directly config(['keystone.cache.enabled' => false]);
Or use the array cache store (set by default in the test TestCase):
// Use an in-memory array cache that is automatically cleared between tests config(['keystone.cache.store' => 'array']);
License
MIT — see LICENSE.