Search by

schtzie / laravel-keystone

schtzie

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.

Package info

github.com/schtzie/laravel-keystone

pkg:composer/schtzie/laravel-keystone

Statistics

Installs: 20

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v2.3.1 2026-09-11 02:31 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.

Latest Version on Packagist Total Downloads Tests Laravel PHP License

Table of Contents

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:

  1. Replay Protection: Verifies an X-Timestamp header to block captured-request replay attacks.
  2. HMAC-SHA256 Auth: Verifies a cryptographically secure signature (X-API-Signature) using a secret key that is never transmitted.
  3. Payload Signing: Verifies an X-Body-Hash header to ensure the request body hasn't been tampered with in transit.
  4. Scope & IP Enforcement: Validates that the key possesses the required route scopes and originates from an allowed IP subnet.
  5. Rate Limiting: Enforces global or per-key request limits using highly concurrent Redis Token Bucket or Sliding Window strategies.
  6. 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 redis cache 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 client identifier in the X-Client-Id header
  • Compute the HMAC-SHA256 signature (hash_hmac('sha256', client, secret)) and send it in X-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 returns 403 Forbidden:
    { "message": "IP address not allowed." }
  • Blocklist (ip_blocklist): Requests originating from blacklisted IPs or subnets are blocked, returning 403 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's IpUtils.

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) → the KeystoneServiceProvider Eloquent observer handles this
  • Keystone::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 secret is 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 keystenant_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.