Security

ZephyrPHP takes security seriously, providing built-in protection against common web vulnerabilities.

Security Features

  • Authentication - Session and JWT-based auth with remember me
  • Authorization - Gates, policies, and role-based access control
  • Password Hashing - Argon2id/bcrypt with automatic rehashing
  • Rate Limiting - Multiple drivers (File, Redis, APCu, Database)
  • Encryption - AES-256-GCM authenticated encryption
  • JWT Tokens - Secure API authentication with refresh tokens
  • CSRF Protection - Automatic token verification for forms
  • XSS Prevention - Automatic output escaping in Twig
  • SQL Injection Prevention - Parameterized queries with Doctrine
  • Security Headers - CSP with nonces, HSTS, modern isolation headers
  • Input Sanitization - Built-in sanitization helpers
  • Secure File Uploads - Type validation and safe storage

CSRF Protection

Cross-Site Request Forgery (CSRF) attacks trick users into performing unwanted actions. ZephyrPHP automatically protects against these attacks.

Using CSRF Tokens in Forms

Include the CSRF token in every form that submits data:

<form method="POST" action="/users">
    {{ csrf_field()|raw }}

    <input type="text" name="name">
    <button type="submit">Create User</button>
</form>

This generates a hidden input field:

<input type="hidden" name="_token" value="abc123...">

AJAX Requests

For AJAX requests, include the token in a header:

// Get the token from a meta tag
<meta name="csrf-token" content="{{ csrf_token() }}">

// Include in AJAX requests
fetch('/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
    },
    body: JSON.stringify(data)
});

Verifying CSRF Tokens

The framework automatically verifies CSRF tokens for POST, PUT, PATCH, and DELETE requests. You can also verify manually:

<?php

use ZephyrPHP\Security\Security;

$security = new Security();

if (!$security->verifyCsrf($this->request->input('_token'))) {
    abort(403, 'Invalid CSRF token');
}

XSS Prevention

Cross-Site Scripting (XSS) attacks inject malicious scripts into web pages. Twig automatically escapes all output to prevent this:

{# This is safe - automatically escaped #}
{{ user_input }}

{# Output: <script>alert('xss')</script> #}
{# Rendered as text, not executed #}
Caution

Only use |raw when you're absolutely certain the content is safe:

{# Only for trusted content like admin-generated HTML #}
{{ trusted_html|raw }}

Sanitizing Input

Sanitize user input before storing:

<?php

use ZephyrPHP\Security\Security;

$security = new Security();

// Sanitize input (removes dangerous characters)
$name = $security->sanitizeInput($_POST['name']);

// HTML purify (allows safe HTML, removes dangerous tags)
$content = $security->purifyHtml($_POST['content']);

// Strip all HTML tags
$text = strip_tags($_POST['bio']);

SQL Injection Prevention

ZephyrPHP uses Doctrine ORM with prepared statements, preventing SQL injection automatically:

// Safe - uses parameterized queries
$users = User::query()
    ->where('email', '=', $userInput)
    ->all();

// Also safe - Doctrine handles escaping
$user = User::findOneBy(['email' => $userInput]);

When using raw queries, always use parameters:

// SAFE - parameterized
$users = $connection->fetchAllAssociative(
    'SELECT * FROM users WHERE email = ?',
    [$userInput]
);

// UNSAFE - never do this!
$users = $connection->fetchAllAssociative(
    "SELECT * FROM users WHERE email = '$userInput'"
);

Security Headers

ZephyrPHP automatically adds security headers to responses:

Header Purpose
X-Content-Type-Options Prevents MIME-type sniffing
X-Frame-Options Prevents clickjacking attacks
X-XSS-Protection Enables browser XSS filtering
Referrer-Policy Controls referrer information
Content-Security-Policy Controls resource loading with nonce support
Permissions-Policy Controls browser features and APIs
Strict-Transport-Security Enforces HTTPS (production only)

Content Security Policy (CSP)

ZephyrPHP provides comprehensive CSP support with nonce-based inline scripts and styles, protecting your application from XSS attacks while still allowing necessary inline code.

CSP Levels

Configure CSP enforcement level via environment variables or the security config:

Level Description
strict No unsafe-inline styles, strict-dynamic for scripts
moderate Nonce-based scripts/styles, allows style-src-attr inline (default)
relaxed More permissive, allows unsafe-inline styles
# .env configuration
CSP_ENABLED=true
CSP_LEVEL=moderate
CSP_REPORT_ONLY=false
CSP_REPORT_URI=/api/csp-report

Using CSP Nonces in Templates

ZephyrPHP automatically generates unique nonces per request. Use them in your Twig templates:

{# Method 1: Get nonce for manual use #}
<script nonce="{{ csp_nonce() }}">
    console.log('This script has a nonce!');
</script>

{# Method 2: Use the csp_script helper (recommended) #}
{{ csp_script("console.log('Hello, secure world!');") }}

{# Method 3: For styles #}
<style nonce="{{ csp_style_nonce() }}">
    .highlight { color: blue; }
</style>

{# Or use the csp_style helper #}
{{ csp_style('.highlight { color: blue; }') }}

{# Get nonce as HTML attribute #}
<script {{ nonce_attr()|raw }}>...</script>

CSP Meta Tags for Dynamic Scripts

For JavaScript that needs to create inline scripts dynamically, include the CSP meta tag:

{# In your layout head #}
{{ csp_meta() }}

{# This outputs: #}
{# <meta name="csp-nonce" content="abc123..."> #}
{# <meta name="csp-style-nonce" content="def456..."> #}

Then in your JavaScript:

// Get nonce from meta tag
const nonce = document.querySelector('meta[name="csp-nonce"]').content;

// Create a script with the nonce
const script = document.createElement('script');
script.nonce = nonce;
script.textContent = 'console.log("Dynamic script with nonce");';
document.body.appendChild(script);

Adding Custom CSP Sources

Add trusted sources programmatically or via environment variables:

<?php
use ZephyrPHP\Security\Headers;

// Add a CDN for scripts
Headers::addCspSource('script-src', 'https://cdn.example.com');

// Add multiple sources
Headers::addCspSource('img-src', [
    'https://images.example.com',
    'https://cdn.example.com'
]);

// Via environment variables (comma-separated)
// .env: CSP_SCRIPT_SRC=https://cdn.example.com,https://api.example.com

CSP Report-Only Mode

Test your CSP policy without blocking violations:

<?php
use ZephyrPHP\Security\Headers;

// Enable report-only mode
Headers::reportOnly(true);

// Set violation report endpoint
Headers::setReportUri('/api/csp-report');

// Via .env
// CSP_REPORT_ONLY=true
// CSP_REPORT_URI=/api/csp-report

Violations are logged to storage/logs/csp-violations.log.

Security Configuration File

Configure all security settings in config/security.php:

<?php
return [
    'csp' => [
        'enabled' => true,
        'level' => 'moderate', // strict, moderate, relaxed
        'use_nonces' => true,
        'report_only' => false,
        'report_uri' => '/api/csp-report',
        'custom_directives' => [
            'script-src' => ['https://cdn.example.com'],
            'style-src' => ['https://fonts.googleapis.com'],
        ],
    ],
    'hsts' => [
        'enabled' => true,
        'max_age' => 31536000,
        'include_subdomains' => true,
        'preload' => false,
    ],
    'isolation' => [
        'enabled' => false,
        'coep' => 'credentialless',
        'coop' => 'same-origin',
        'corp' => 'same-origin',
    ],
];

Modern Isolation Headers

Enable cross-origin isolation for powerful features like SharedArrayBuffer:

<?php
use ZephyrPHP\Security\Headers;

// Enable isolation headers
Headers::useIsolationHeaders(true);

// Via .env
// USE_ISOLATION_HEADERS=true
// COEP=credentialless
// COOP=same-origin
// CORP=same-origin
Warning

Isolation headers may break third-party resources (iframes, images, scripts). Test thoroughly before enabling in production.

Authentication

ZephyrPHP provides a complete authentication system with session and JWT support.

Basic Authentication

<?php
use ZephyrPHP\Auth\Auth;

// Attempt login
if (Auth::attempt(['email' => $email, 'password' => $password])) {
    // Authentication successful
    return redirect('/dashboard');
}

// With remember me
Auth::attempt($credentials, remember: true);

// Check if authenticated
if (Auth::check()) {
    $user = Auth::user();
    $userId = Auth::id();
}

// Logout
Auth::logout();

User Model Setup

Your User model must implement AuthenticatableInterface:

<?php
namespace App\Models;

use ZephyrPHP\Auth\AuthenticatableInterface;
use ZephyrPHP\Auth\Authenticatable;

class User extends Model implements AuthenticatableInterface
{
    use Authenticatable;

    // Your model code...
}

Auth Middleware

<?php
use ZephyrPHP\Middleware\AuthMiddleware;
use ZephyrPHP\Middleware\GuestMiddleware;

// Protect routes (authenticated users only)
Route::get('/dashboard', [DashboardController::class, 'index'])
    ->middleware([AuthMiddleware::class]);

// Guest only routes (login, register)
Route::get('/login', [AuthController::class, 'showLogin'])
    ->middleware([GuestMiddleware::class]);

Password Hashing

Use the Hash class for secure password hashing with Argon2id (recommended) or bcrypt:

<?php
use ZephyrPHP\Security\Hash;

// Hash a password (uses Argon2id by default)
$hash = Hash::make('secret123');

// Verify a password
if (Hash::check('secret123', $hash)) {
    // Password matches
}

// Check if rehashing is needed (algorithm/options changed)
if (Hash::needsRehash($hash)) {
    $user->password = Hash::make($password);
    $user->save();
}

// Use bcrypt instead
Hash::useBcrypt();
Hash::setBcryptCost(12);

// Generate secure random tokens
$token = Hash::randomToken(32);      // Hex encoded
$token = Hash::randomUrlSafeToken(); // URL-safe base64
Never

Never store passwords in plain text or use MD5/SHA1 for password hashing.

Authorization (Gates & Policies)

Control what users can do with abilities and policies:

Defining Abilities

<?php
use ZephyrPHP\Auth\Gate;

// Define abilities in a service provider
Gate::define('edit-post', function($user, $post) {
    return $user->id === $post->user_id;
});

Gate::define('admin', function($user) {
    return $user->role === 'admin';
});

Checking Permissions

<?php
// Check if allowed
if (Gate::allows('edit-post', $post)) {
    // Can edit
}

if (Gate::denies('edit-post', $post)) {
    abort(403);
}

// Authorize (throws exception if denied)
Gate::authorize('edit-post', $post);

// Check multiple abilities
if (Gate::all(['edit-post' => $post, 'publish-post' => $post])) { ... }
if (Gate::any(['edit-post', 'delete-post'], $post)) { ... }

Policies

<?php
// Register policy
Gate::policy(Post::class, PostPolicy::class);

// PostPolicy.php
class PostPolicy
{
    public function update($user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }

    public function delete($user, Post $post): bool
    {
        return $user->id === $post->user_id || $user->isAdmin();
    }
}

Authorization Middleware

<?php
use ZephyrPHP\Middleware\CanMiddleware;

Route::put('/posts/{id}', [PostController::class, 'update'])
    ->middleware([new CanMiddleware('update', Post::class)]);

Route::get('/admin', [AdminController::class, 'index'])
    ->middleware([CanMiddleware::role('admin')]);

JWT Authentication (API)

Secure your APIs with JSON Web Tokens:

Generating Tokens

<?php
use ZephyrPHP\Auth\JwtToken;

// Configure (in service provider)
JwtToken::configure([
    'secret' => env('JWT_SECRET'),
    'lifetime' => 3600,        // 1 hour
    'issuer' => 'your-app',
]);

// Generate token for user
$token = JwtToken::forUser($user);

// Generate with custom payload
$token = JwtToken::generate([
    'user_id' => $user->id,
    'role' => $user->role,
]);

// Generate refresh token (long-lived)
$refreshToken = JwtToken::generateRefreshToken(['user_id' => $user->id]);

Validating Tokens

<?php
// Validate and decode
$payload = JwtToken::validate($token);

if ($payload === null) {
    // Invalid or expired
}

// Check if valid
if (JwtToken::isValid($token)) { ... }

// Get time until expiration
$seconds = JwtToken::expiresIn($token);

// Refresh a token
$newToken = JwtToken::refresh($oldToken);

API Auth Middleware

<?php
use ZephyrPHP\Middleware\ApiAuthMiddleware;

Route::prefix('/api')->middleware([ApiAuthMiddleware::class])->group(function() {
    Route::get('/user', [ApiController::class, 'user']);
    Route::get('/posts', [ApiController::class, 'posts']);
});

// Client usage:
// Authorization: Bearer <token>

Encryption

Encrypt sensitive data with AES-256-GCM authenticated encryption:

<?php
use ZephyrPHP\Security\Encryption;

// Set key (usually from .env APP_KEY)
Encryption::setKey(env('APP_KEY'));

// Generate a new key
$key = Encryption::generateKey(); // base64:...

// Encrypt string
$encrypted = Encryption::encrypt('sensitive data');

// Decrypt string
$decrypted = Encryption::decrypt($encrypted);

// Encrypt arrays/objects
$encrypted = Encryption::encryptArray(['ssn' => '123-45-6789']);
$data = Encryption::decryptArray($encrypted);

// Create signed tokens (for URLs, etc.)
$token = Encryption::signedToken(['user_id' => 123], time() + 3600);
$data = Encryption::verifySignedToken($token); // null if invalid/expired

Secure File Uploads

Validate and safely handle file uploads:

<?php

use ZephyrPHP\Security\Security;

$security = new Security();

if ($this->request->hasFile('avatar')) {
    $file = $this->request->file('avatar');

    // Validate file type
    $allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];

    if (!in_array($file['type'], $allowedTypes)) {
        abort(400, 'Invalid file type');
    }

    // Validate file size (e.g., 2MB max)
    if ($file['size'] > 2 * 1024 * 1024) {
        abort(400, 'File too large');
    }

    // Generate safe filename
    $extension = pathinfo($file['name'], PATHINFO_EXTENSION);
    $safeFilename = bin2hex(random_bytes(16)) . '.' . $extension;

    // Move to safe location
    $uploadPath = storage_path('uploads/' . $safeFilename);
    move_uploaded_file($file['tmp_name'], $uploadPath);
}

Environment Security

  • Never commit .env files to version control
  • Keep APP_DEBUG=false in production
  • Regenerate APP_KEY if compromised
  • Use HTTPS in production
# Production .env
APP_ENV=production
APP_DEBUG=false
APP_KEY=your-secret-key-here

# Set secure session cookies
SESSION_SECURE=true

Rate Limiting

Protect against brute force attacks and API abuse with the built-in rate limiter:

Basic Usage

<?php
use ZephyrPHP\Security\RateLimiter;

$key = 'login:' . $request->ip();

// Check if rate limited
if (RateLimiter::tooManyAttempts($key, maxAttempts: 5)) {
    $retryAfter = RateLimiter::availableIn($key);
    abort(429, "Too many attempts. Try again in {$retryAfter} seconds.");
}

// Increment counter (60 second decay)
RateLimiter::hit($key, decaySeconds: 60);

// Get remaining attempts
$remaining = RateLimiter::remaining($key, 5);

// Clear rate limit
RateLimiter::clear($key);

Attempt Helper

<?php
// Execute callback if not rate limited
$result = RateLimiter::attempt(
    key: 'api:' . $userId,
    maxAttempts: 100,
    callback: fn() => $this->handleRequest(),
    decaySeconds: 60
);

if ($result === false) {
    // Rate limited
}

Rate Limit Middleware

<?php
use ZephyrPHP\Middleware\RateLimitMiddleware;

// 60 requests per minute
Route::prefix('/api')->middleware([RateLimitMiddleware::perMinute(60)])
    ->group(function() { ... });

// 5 login attempts per 15 minutes
Route::post('/login', [AuthController::class, 'login'])
    ->middleware([RateLimitMiddleware::loginAttempts(5)]);

// Custom rate limit
Route::middleware([new RateLimitMiddleware(100, 3600)]) // 100/hour
    ->group(function() { ... });

Storage Drivers

<?php
// File storage (default)
RateLimiter::driver('file');

// Redis (recommended for production)
RateLimiter::driver('redis');

// Or set Redis connection directly
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
RateLimiter::setRedis($redis);

// APCu (in-memory, single server)
RateLimiter::driver('apcu');

// Database
RateLimiter::setDatabase($pdo);
RateLimiter::createTable($pdo); // Create table if needed

Rate Limit Headers

<?php
// Get headers for response
$headers = RateLimiter::headers($key, maxAttempts: 60);
// Returns:
// X-RateLimit-Limit: 60
// X-RateLimit-Remaining: 55
// X-RateLimit-Reset: 1234567890
// Retry-After: 45
Production Security Checklist
  • Set APP_DEBUG=false and ENV=production
  • Generate strong APP_KEY and JWT_SECRET
  • Enable HTTPS and set SESSION_SECURE=true
  • Configure CSP with appropriate sources
  • Enable HSTS with preload for production domains
  • Implement rate limiting on authentication endpoints
  • Use Argon2id for password hashing
  • Validate and sanitize all user input
  • Use parameterized database queries
  • Validate file uploads (type, size, content)
  • Keep all dependencies updated
  • Monitor CSP violation reports
  • Never commit .env files to version control
  • Regularly rotate encryption keys and tokens

OWASP Top 10 Protection

ZephyrPHP provides built-in protection against the OWASP Top 10 vulnerabilities:

Vulnerability Protection
A01: Broken Access Control Gates, Policies, Auth Middleware
A02: Cryptographic Failures AES-256-GCM encryption, Argon2id hashing
A03: Injection Parameterized queries, Input sanitization
A04: Insecure Design Security-first architecture, RBAC
A05: Security Misconfiguration Secure defaults, Security headers
A06: Vulnerable Components Minimal dependencies, Composer audit
A07: Auth Failures Rate limiting, Session security, JWT
A08: Data Integrity Failures CSRF protection, Signed tokens
A09: Logging Failures CSP violation logging, Error logging
A10: SSRF URL validation, Input sanitization