17 KiB
LiveComponents Security Guide
Complete security guide for LiveComponents covering CSRF protection, rate limiting, idempotency, authorization, and best practices.
Table of Contents
- CSRF Protection
- Rate Limiting
- Idempotency
- Action Allow-List
- Authorization
- Input Validation
- XSS Prevention
- Secure State Management
- Security Checklist
CSRF Protection
How It Works
LiveComponents automatically protect all actions with CSRF tokens:
- Token Generation: Unique token generated per component instance
- Token Validation: Token validated on every action request
- Token Regeneration: Token regenerated after each action
Implementation
Server-Side (Automatic):
// CSRF token is automatically generated and validated
#[Action]
public function increment(): CounterState
{
// CSRF validation happens automatically before this method is called
return $this->state->increment();
}
Client-Side (Automatic):
<!-- CSRF token is automatically included in action requests -->
<button data-live-action="increment">Increment</button>
Manual CSRF Token Access
If you need to access CSRF tokens manually:
use App\Framework\LiveComponents\ComponentRegistry;
$registry = container()->get(ComponentRegistry::class);
$csrfToken = $registry->generateCsrfToken($componentId);
CSRF Token Format
- Length: 32 hexadecimal characters
- Format:
[a-f0-9]{32} - Scope: Per component instance (not global)
Best Practices
✅ DO:
- Let the framework handle CSRF automatically
- Include CSRF meta tag in base layout:
<meta name="csrf-token" content="{csrf_token}"> - Use HTTPS in production
❌ DON'T:
- Disable CSRF protection
- Share CSRF tokens between components
- Store CSRF tokens in localStorage (use session)
Rate Limiting
Overview
Rate limiting prevents abuse by limiting the number of actions per time period.
Configuration
Global Rate Limit (.env):
LIVECOMPONENT_RATE_LIMIT=60 # 60 requests per minute per component
Per-Action Rate Limit:
#[Action(rateLimit: 10)] // 10 requests per minute for this action
public function expensiveOperation(): State
{
// Implementation
}
Rate Limit Headers
When rate limit is exceeded, response includes:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Retry-After: 30
Client-Side Handling
// Automatic retry after Retry-After header
LiveComponent.executeAction('counter:demo', 'increment')
.catch(error => {
if (error.status === 429) {
const retryAfter = error.headers['retry-after'];
console.log(`Rate limited. Retry after ${retryAfter} seconds`);
}
});
Best Practices
✅ DO:
- Set appropriate rate limits based on action cost
- Use higher limits for read operations, lower for write operations
- Monitor rate limit violations
❌ DON'T:
- Set rate limits too low (hurts UX)
- Set rate limits too high (allows abuse)
- Ignore rate limit violations
Idempotency
Overview
Idempotency ensures that repeating the same action multiple times has the same effect as performing it once.
Configuration
Per-Action Idempotency:
#[Action(idempotencyTTL: 60)] // Idempotent for 60 seconds
public function processPayment(float $amount): State
{
// This action will return cached result if called again within 60 seconds
return $this->state->processPayment($amount);
}
How It Works
- First Request: Action executes, result cached with idempotency key
- Subsequent Requests: Same idempotency key returns cached result
- After TTL: Cache expires, action can execute again
Idempotency Key
Client-Side:
// Generate unique idempotency key
const idempotencyKey = `payment-${Date.now()}-${Math.random()}`;
LiveComponent.executeAction('payment:demo', 'processPayment', {
amount: 100.00,
idempotency_key: idempotencyKey
});
Server-Side (Automatic):
- Idempotency key extracted from request
- Key includes: component ID + action name + parameters
- Cached result returned if key matches
Use Cases
✅ Good for:
- Payment processing
- Order creation
- Email sending
- External API calls
❌ Not suitable for:
- Incrementing counters
- Appending to lists
- Time-sensitive operations
Best Practices
✅ DO:
- Use idempotency for critical operations
- Set appropriate TTL (long enough to prevent duplicates, short enough to allow retries)
- Include idempotency key in client requests
❌ DON'T:
- Use idempotency for operations that should execute multiple times
- Set TTL too long (prevents legitimate retries)
- Rely solely on idempotency for security
Action Allow-List
Overview
Only methods marked with #[Action] can be called from the client.
Implementation
#[LiveComponent('user-profile')]
final readonly class UserProfileComponent implements LiveComponentContract
{
// ✅ Can be called from client
#[Action]
public function updateProfile(array $data): State
{
return $this->state->updateProfile($data);
}
// ❌ Cannot be called from client (no #[Action] attribute)
public function internalMethod(): void
{
// This method is not exposed to the client
}
// ❌ Reserved methods cannot be actions
public function mount(): State
{
// Reserved method - cannot be called as action
}
}
Reserved Methods
These methods cannot be actions:
mount()getRenderData()getId()getState()getData()- Methods starting with
_(private convention)
Security Benefits
- Explicit API: Only intended methods are callable
- No Accidental Exposure: Internal methods stay internal
- Clear Intent:
#[Action]makes API surface explicit
Best Practices
✅ DO:
- Mark all public methods that should be callable with
#[Action] - Keep internal methods without
#[Action] - Use descriptive action names
❌ DON'T:
- Mark internal methods with
#[Action] - Expose sensitive operations without proper authorization
- Use generic action names like
do()orexecute()
Authorization
Overview
Use #[RequiresPermission] to restrict actions to authorized users.
Implementation
#[LiveComponent('post-editor')]
final readonly class PostEditorComponent implements LiveComponentContract
{
#[Action]
#[RequiresPermission('posts.edit')]
public function updatePost(array $data): State
{
return $this->state->updatePost($data);
}
#[Action]
#[RequiresPermission('posts.delete')]
public function deletePost(): State
{
return $this->state->deletePost();
}
// Multiple permissions (user needs ALL)
#[Action]
#[RequiresPermission('posts.edit', 'posts.publish')]
public function publishPost(): State
{
return $this->state->publishPost();
}
}
Permission Checking
Custom Authorization Checker:
use App\Framework\LiveComponents\Security\AuthorizationCheckerInterface;
final readonly class CustomAuthorizationChecker implements AuthorizationCheckerInterface
{
public function hasPermission(string $permission): bool
{
// Check if current user has permission
return $this->user->hasPermission($permission);
}
public function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (!$this->hasPermission($permission)) {
return false;
}
}
return true;
}
}
Unauthorized Access
When user lacks required permission:
{
"success": false,
"error": {
"code": "UNAUTHORIZED",
"message": "User does not have required permission: posts.edit"
}
}
Best Practices
✅ DO:
- Use authorization for sensitive operations
- Check permissions server-side (never trust client)
- Use specific permissions (not generic like 'admin')
- Log authorization failures
❌ DON'T:
- Rely on client-side authorization checks
- Use overly broad permissions
- Skip authorization for write operations
- Expose permission names in error messages
Input Validation
Overview
Always validate input in actions before processing.
Implementation
#[Action]
public function updateProfile(array $data): State
{
// Validate input
$errors = [];
if (empty($data['name'])) {
$errors[] = 'Name is required';
}
if (isset($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address';
}
if (isset($data['age']) && ($data['age'] < 0 || $data['age'] > 150)) {
$errors[] = 'Age must be between 0 and 150';
}
if (!empty($errors)) {
throw new ValidationException('Validation failed', $errors);
}
// Process valid data
return $this->state->updateProfile($data);
}
Type Safety
Use type hints for automatic validation:
#[Action]
public function addAmount(int $amount): State
{
// PHP automatically validates $amount is an integer
if ($amount < 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
return $this->state->addAmount($amount);
}
Value Objects
Use Value Objects for complex validation:
#[Action]
public function updateEmail(Email $email): State
{
// Email Value Object validates format automatically
return $this->state->updateEmail($email);
}
Best Practices
✅ DO:
- Validate all input in actions
- Use type hints for automatic validation
- Use Value Objects for complex data
- Return clear error messages
❌ DON'T:
- Trust client input
- Skip validation for "internal" actions
- Expose internal validation details
- Use generic error messages
XSS Prevention
Overview
LiveComponents automatically escape output in templates.
Template Escaping
Automatic Escaping:
<!-- Automatically escaped -->
<div>{user_input}</div>
<!-- Safe HTML (if needed) -->
<div>{html_content|raw}</div>
Best Practices
✅ DO:
- Let the framework escape output automatically
- Use
|rawfilter only when necessary - Validate HTML content before storing
- Use Content Security Policy (CSP)
❌ DON'T:
- Disable automatic escaping
- Use
|rawwith user input - Store unvalidated HTML
- Trust client-provided HTML
Secure State Management
Overview
Component state should never contain sensitive data.
Sensitive Data
❌ DON'T Store:
- Passwords
- API keys
- Credit card numbers
- Session tokens
- Private keys
✅ DO Store:
- User IDs (not passwords)
- Display preferences
- UI state
- Non-sensitive configuration
State Encryption
For sensitive state (if absolutely necessary):
use App\Framework\Encryption\EncryptionService;
final readonly class SecureComponent implements LiveComponentContract
{
public function __construct(
private EncryptionService $encryption
) {
}
public function storeSensitiveData(string $data): State
{
$encrypted = $this->encryption->encrypt($data);
return $this->state->withEncryptedData($encrypted);
}
}
Best Practices
✅ DO:
- Keep state minimal
- Store only necessary data
- Use server-side storage for sensitive data
- Encrypt sensitive state if needed
❌ DON'T:
- Store passwords or secrets in state
- Include sensitive data in state
- Trust client-provided state
- Expose internal implementation details
Security Checklist
Component Development
- All actions marked with
#[Action] - No sensitive data in component state
- Input validation in all actions
- Authorization checks for sensitive operations
- Rate limiting configured appropriately
- Idempotency for critical operations
- CSRF protection enabled (automatic)
- Error messages don't expose sensitive information
Testing
- CSRF token validation tested
- Rate limiting tested
- Authorization tested
- Input validation tested
- XSS prevention tested
- Error handling tested
Deployment
- HTTPS enabled
- CSRF protection enabled
- Rate limits configured
- Error reporting configured
- Security headers configured
- Content Security Policy configured
Common Security Pitfalls
1. Trusting Client State
❌ WRONG:
#[Action]
public function updateBalance(float $newBalance): State
{
// Don't trust client-provided balance!
return $this->state->withBalance($newBalance);
}
✅ CORRECT:
#[Action]
public function addToBalance(float $amount): State
{
// Calculate new balance server-side
$newBalance = $this->state->balance + $amount;
return $this->state->withBalance($newBalance);
}
2. Skipping Authorization
❌ WRONG:
#[Action]
public function deleteUser(int $userId): State
{
// No authorization check!
$this->userService->delete($userId);
return $this->state;
}
✅ CORRECT:
#[Action]
#[RequiresPermission('users.delete')]
public function deleteUser(int $userId): State
{
$this->userService->delete($userId);
return $this->state;
}
3. Exposing Sensitive Data
❌ WRONG:
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(
templatePath: 'user-profile',
data: [
'password' => $this->user->password, // ❌ Never expose passwords!
'api_key' => $this->user->apiKey, // ❌ Never expose API keys!
]
);
}
✅ CORRECT:
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(
templatePath: 'user-profile',
data: [
'username' => $this->user->username,
'email' => $this->user->email,
// Only include safe, display data
]
);
}
Security Patterns
Pattern 1: Secure File Upload
#[LiveComponent('file-uploader')]
final readonly class FileUploaderComponent implements LiveComponentContract, SupportsFileUpload
{
public function validateUpload(UploadedFile $file): array
{
$errors = [];
// Check file type
$allowedTypes = ['image/jpeg', 'image/png'];
if (!in_array($file->getMimeType(), $allowedTypes)) {
$errors[] = 'Invalid file type';
}
// Check file size
if ($file->getSize() > 5 * 1024 * 1024) {
$errors[] = 'File too large';
}
// Check file extension
$extension = pathinfo($file->getName(), PATHINFO_EXTENSION);
$allowedExtensions = ['jpg', 'jpeg', 'png'];
if (!in_array(strtolower($extension), $allowedExtensions)) {
$errors[] = 'Invalid file extension';
}
return $errors;
}
#[Action]
#[RequiresPermission('files.upload')]
public function handleUpload(UploadedFile $file): State
{
// Additional server-side validation
$errors = $this->validateUpload($file);
if (!empty($errors)) {
throw new ValidationException('Upload validation failed', $errors);
}
// Process upload securely
$path = $this->storage->store($file);
return $this->state->withUploadedFile($path);
}
}
Pattern 2: Secure Payment Processing
#[LiveComponent('payment')]
final readonly class PaymentComponent implements LiveComponentContract
{
#[Action]
#[RequiresPermission('payments.process')]
#[Action(rateLimit: 5, idempotencyTTL: 300)] // 5 per minute, 5 min idempotency
public function processPayment(
float $amount,
string $paymentMethod,
string $idempotencyKey
): State {
// Validate amount
if ($amount <= 0 || $amount > 10000) {
throw new ValidationException('Invalid amount');
}
// Validate payment method
$allowedMethods = ['credit_card', 'paypal'];
if (!in_array($paymentMethod, $allowedMethods)) {
throw new ValidationException('Invalid payment method');
}
// Process payment (idempotent)
$transaction = $this->paymentService->process(
$amount,
$paymentMethod,
$idempotencyKey
);
return $this->state->withTransaction($transaction);
}
}
Next Steps
- Performance Guide - Optimization techniques
- End-to-End Guide - Complete development guide
- API Reference - Complete API documentation