API Authentication¶
The API package provides secure authentication with bcrypt-hashed API keys, scope-based permissions, and automatic key rotation.
API Key Management¶
Creating Keys¶
use Mod\Api\Models\ApiKey;
$apiKey = ApiKey::create([
'name' => 'Mobile App Production',
'workspace_id' => $workspace->id,
'scopes' => ['posts:read', 'posts:write', 'categories:read'],
'rate_limit_tier' => 'pro',
'expires_at' => now()->addYear(),
]);
// Get plaintext key (only available once!)
$plaintext = $apiKey->plaintext_key;
// Returns: sk_live_abc123def456...
Key Format: {prefix}_{environment}_{random}
- Prefix: sk (secret key)
- Environment: live or test
- Random: 32-character string
Secure Storage¶
Keys are hashed with bcrypt before storage:
// Never stored in plaintext
$hash = bcrypt($plaintext);
// Stored in database
$apiKey->key_hash = $hash;
// Verification
if (Hash::check($providedKey, $apiKey->key_hash)) {
// Valid key
}
Key Rotation¶
Rotate keys with a grace period:
$newKey = $apiKey->rotate([
'grace_period_hours' => 24,
]);
// Returns new ApiKey with:
// - New plaintext key
// - Same scopes and settings
// - Old key marked for deletion after grace period
During the grace period, both keys work. After 24 hours, the old key is automatically deleted.
Using API Keys¶
Authorization Header¶
Basic Auth¶
PHP Example¶
use GuzzleHttp\Client;
$client = new Client([
'base_uri' => 'https://api.example.com',
'headers' => [
'Authorization' => "Bearer {$apiKey}",
'Accept' => 'application/json',
],
]);
$response = $client->get('/v1/posts');
JavaScript Example¶
const response = await fetch('https://api.example.com/v1/posts', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Accept': 'application/json'
}
});
Scopes & Permissions¶
Defining Scopes¶
$apiKey = ApiKey::create([
'scopes' => [
'posts:read', // Read posts
'posts:write', // Create/update posts
'posts:delete', // Delete posts
'categories:read', // Read categories
],
]);
Common Scopes¶
| Scope | Description |
|---|---|
{resource}:read |
Read access |
{resource}:write |
Create and update |
{resource}:delete |
Delete access |
{resource}:* |
All permissions for resource |
* |
Full access (use sparingly!) |
Wildcard Scopes¶
// All post permissions
'scopes' => ['posts:*']
// Read access to all resources
'scopes' => ['*:read']
// Full access (admin only!)
'scopes' => ['*']
Scope Enforcement¶
Protect routes with scope middleware:
Route::middleware('scope:posts:write')
->post('/posts', [PostController::class, 'store']);
Route::middleware('scope:posts:delete')
->delete('/posts/{id}', [PostController::class, 'destroy']);
Check Scopes in Controllers¶
public function store(Request $request)
{
if (!$request->user()->tokenCan('posts:write')) {
return response()->json([
'error' => 'Insufficient permissions',
'required_scope' => 'posts:write',
], 403);
}
return Post::create($request->validated());
}
Rate Limiting¶
Keys are rate-limited based on tier:
// config/api.php
'rate_limits' => [
'free' => ['requests' => 1000, 'per' => 'hour'],
'pro' => ['requests' => 10000, 'per' => 'hour'],
'business' => ['requests' => 50000, 'per' => 'hour'],
'enterprise' => ['requests' => null], // Unlimited
],
Rate limit headers included in responses:
Learn more about Rate Limiting →
Key Expiration¶
Set Expiration¶
Check Expiration¶
Auto-Cleanup¶
Expired keys are automatically cleaned up:
Environment-Specific Keys¶
Test Keys¶
$testKey = ApiKey::create([
'name' => 'Development Key',
'environment' => 'test',
]);
// Key prefix: sk_test_...
Test keys: - Don't affect production data - Higher rate limits - Clearly marked in UI - Easy to identify and delete
Live Keys¶
Middleware¶
API Authentication¶
Scope Enforcement¶
use Mod\Api\Middleware\EnforceApiScope;
Route::middleware([EnforceApiScope::class.':posts:write'])
->post('/posts', [PostController::class, 'store']);
Rate Limiting¶
use Mod\Api\Middleware\RateLimitApi;
Route::middleware(RateLimitApi::class)->group(function () {
// Rate-limited routes
});
Security Best Practices¶
1. Minimum Required Scopes¶
// ✅ Good - specific scopes
'scopes' => ['posts:read', 'categories:read']
// ❌ Bad - excessive permissions
'scopes' => ['*']
2. Rotate Regularly¶
// Rotate every 90 days
if ($apiKey->created_at->diffInDays() > 90) {
$newKey = $apiKey->rotate();
// Notify user of new key
}
3. Use Separate Keys Per Client¶
// ✅ Good - separate keys
ApiKey::create(['name' => 'iOS App']);
ApiKey::create(['name' => 'Android App']);
ApiKey::create(['name' => 'Web App']);
// ❌ Bad - shared key
ApiKey::create(['name' => 'All Mobile Apps']);
4. Set Expiration¶
// ✅ Good - temporary access
'expires_at' => now()->addMonths(6)
// ❌ Bad - never expires
'expires_at' => null
5. Monitor Usage¶
$usage = ApiKey::find($id)->usage()
->whereBetween('created_at', [now()->subDays(7), now()])
->count();
if ($usage > $threshold) {
// Alert admin
}
Testing¶
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Mod\Api\Models\ApiKey;
class ApiKeyAuthTest extends TestCase
{
public function test_authenticates_with_valid_key(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'],
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->getJson('/api/v1/posts');
$response->assertOk();
}
public function test_rejects_invalid_key(): void
{
$response = $this->withHeaders([
'Authorization' => 'Bearer invalid_key',
])->getJson('/api/v1/posts');
$response->assertUnauthorized();
}
public function test_enforces_scopes(): void
{
$apiKey = ApiKey::factory()->create([
'scopes' => ['posts:read'], // No write permission
]);
$response = $this->withHeaders([
'Authorization' => "Bearer {$apiKey->plaintext_key}",
])->postJson('/api/v1/posts', ['title' => 'Test']);
$response->assertForbidden();
}
}