7.4 KiB
7.4 KiB
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:
liveortest - 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
curl -H "Authorization: Bearer sk_live_abc123..." \
https://api.example.com/v1/posts
Basic Auth
curl -u sk_live_abc123: \
https://api.example.com/v1/posts
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:
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9847
X-RateLimit-Reset: 1640995200
Learn more about Rate Limiting →
Key Expiration
Set Expiration
$apiKey = ApiKey::create([
'expires_at' => now()->addMonths(6),
]);
Check Expiration
if ($apiKey->isExpired()) {
return response()->json(['error' => 'API key expired'], 401);
}
Auto-Cleanup
Expired keys are automatically cleaned up:
php artisan api:prune-expired-keys
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
$liveKey = ApiKey::create([
'environment' => 'live',
]);
// Key prefix: sk_live_...
Middleware
API Authentication
Route::middleware('auth:api')->group(function () {
// Protected routes
});
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();
}
}