Security Guide
This document outlines security best practices and configuration for Tenanto.
Table of Contents
- Security Overview
- Tenant Isolation
- Authentication Security
- API Security
- Input Validation
- OWASP Top 10
- Security Headers
- Environment Security
- Logging & Monitoring
- Security Checklist
Security Overview
Tenanto implements security at multiple layers:
┌─────────────────────────────────────────┐
│ Security Layers │
├─────────────────────────────────────────┤
│ 1. Network │ HTTPS, Firewall │
│ 2. Web Server │ Security Headers │
│ 3. Application │ Auth, Validation │
│ 4. Tenant │ Global Scopes, Policy │
│ 5. Database │ Prepared Statements │
│ 6. Audit │ Logging, Monitoring │
└─────────────────────────────────────────┘
Tenant Isolation
Critical Security Boundary
Tenant isolation is the most critical security requirement in a multi-tenant application.
How It Works
- Global Scopes - Automatically filter queries by
tenant_id - Policy Layer - Double-check tenant ownership on every action
- Middleware - Set tenant context from subdomain/domain
Implementation
// BelongsToTenant trait (auto-applied to tenant-aware models)
protected static function bootBelongsToTenant(): void
{
// Auto-assign tenant_id on create
static::creating(function ($model) {
if (empty($model->tenant_id)) {
$model->tenant_id = resolve('currentTenant')?->id;
}
});
// Global scope filters all queries
static::addGlobalScope('tenant', function ($query) {
if ($tenant = resolve('currentTenant')) {
$query->where('tenant_id', $tenant->id);
}
});
}
Testing
Run tenant isolation tests:
php artisan test --group=tenant-isolation
Tests verify:
- Users can only see their tenant's data
- Cross-tenant access returns 403/404
- Created resources auto-assign to user's tenant
- Direct UUID guessing doesn't leak data
Never Do This
// WRONG: Bypasses tenant isolation
Project::withoutGlobalScopes()->get();
// WRONG: Manual tenant_id without validation
$project = Project::find($request->project_id);
// CORRECT: Use policies
$this->authorize('view', $project);
Authentication Security
Password Requirements
Configured in config/fortify.php:
- Minimum 8 characters
- Mixed case, numbers, symbols recommended
- Password validation rules enforced
Email Verification
- Required for login
- Unverified users get 403 response
- Verification emails expire after 60 minutes
Session Security
// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true, // No JS access
'same_site' => 'lax', // CSRF protection
API Token Security
- Tokens hashed with SHA-256
- Single-use logout (revokes all tokens for user)
- No token in URL parameters
Rate Limiting
| Endpoint | Limit | Scope |
|---|---|---|
| Login/Auth | 10 attempts/minute | Per IP |
| API (guest) | 60 requests/minute | Per IP |
| API (auth) | 60 requests/minute | Per user |
| API (read) | 120 requests/minute | Per user |
API Security
Sanctum Configuration
// config/sanctum.php
'expiration' => env('SANCTUM_TOKEN_EXPIRATION', 10080), // 7 days default
'guard' => ['web'],
Token Expiration:
- Default: 7 days (10080 minutes)
- Configurable via
SANCTUM_TOKEN_EXPIRATIONin.env - Tokens automatically expire for security
- Users must re-authenticate after expiration
Required Middleware
All API routes use:
auth:sanctum- Token authenticationtenant- Tenant context setupthrottle:api- Rate limiting
Response Format
Never expose internal errors:
// WRONG
return response()->json(['error' => $exception->getMessage()]);
// CORRECT
return response()->json([
'success' => false,
'message' => 'An error occurred',
], 500);
UUID Usage
- External APIs use UUID (
$model->idviaHasUuidstrait) - Internal database uses auto-increment IDs
- Prevents enumeration attacks
Input Validation
Form Requests
All input validated via FormRequest classes:
class StoreProjectRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
];
}
}
Validation Rules
| Data Type | Validation |
|---|---|
email:rfc,dns |
|
| URL | url:http,https |
| UUID | uuid |
| Enum | Custom rule checking allowed values |
| HTML | Sanitized or stripped |
| Files | MIME type + size validation |
SQL Injection Prevention
Always use Eloquent or prepared statements:
// CORRECT
User::where('email', $email)->first();
DB::select('SELECT * FROM users WHERE id = ?', [$id]);
// WRONG - SQL Injection vulnerable!
DB::select("SELECT * FROM users WHERE id = $id");
LIKE Query Escaping
User input in LIKE queries must be escaped to prevent pattern injection:
use App\Support\Helpers\QueryHelper;
// CORRECT - escaped
$query->where('name', 'like', QueryHelper::likeContains($search));
// ALSO CORRECT - using helper methods
$query->where('name', 'like', QueryHelper::likeStartsWith($search));
$query->where('name', 'like', QueryHelper::likeEndsWith($search));
// WRONG - user can inject % and _ wildcards
$query->where('name', 'like', '%'.$search.'%');
The QueryHelper class escapes %, _, and \ characters to prevent users from:
- Matching all records with
% - Using single-character wildcards with
_ - Bypassing escaping with
\
OWASP Top 10
A01: Broken Access Control
Mitigations:
- Policy-based authorization on all routes
- Tenant scoping via global scopes
- No direct object references without authorization
A02: Cryptographic Failures
Mitigations:
- HTTPS enforced in production
- Passwords hashed with bcrypt
- Sensitive data encrypted at rest (Stripe tokens)
A03: Injection
Mitigations:
- Eloquent ORM prevents SQL injection
- Blade auto-escapes output (XSS prevention)
- FormRequest validation on all input
A04: Insecure Design
Mitigations:
- Defense-in-depth architecture
- Principle of least privilege
- Security reviews in code review checklist
A05: Security Misconfiguration
Mitigations:
.env.examplewith secure defaultsAPP_DEBUG=falsein production- Error pages don't expose details
A06: Vulnerable Components
Mitigations:
composer auditin CI/CDnpm auditin CI/CD- Regular dependency updates
A07: Authentication Failures
Mitigations:
- Rate limiting on login
- Secure session configuration
- Email verification required
A08: Data Integrity Failures
Mitigations:
- CSRF tokens on all forms
- Webhook signature verification (Stripe)
- Input validation on all endpoints
A09: Logging Failures
Mitigations:
- Structured logging with context
- Audit trail for sensitive operations
- Log rotation and retention
A10: Server-Side Request Forgery
Mitigations:
- No user-controlled URLs in server requests
- Webhook URLs validated
- Internal network isolated
Security Headers
Nginx Configuration
Security headers are configured in docker/nginx/sites/tenanto.conf:
# Main tenant block
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy "..." always; # See CSP section below
# Admin block (stricter)
add_header X-Frame-Options "DENY" always; # No framing for admin
add_header Cache-Control "no-store, no-cache, must-revalidate" always; # No caching
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=()" always;
# HSTS - Enable in production with SSL
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
Header Reference:
| Header | Purpose | Value |
|---|---|---|
| X-Frame-Options | Prevent clickjacking | SAMEORIGIN (tenant), DENY (admin) |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff |
| Referrer-Policy | Control referrer leakage | strict-origin-when-cross-origin |
| Permissions-Policy | Restrict browser APIs | Disable camera, mic, geolocation, payment |
| Content-Security-Policy | Control resource loading | See CSP section |
| Cache-Control | Prevent caching (admin) | no-store, no-cache |
Content Security Policy
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
" always;
Why 'unsafe-inline' and 'unsafe-eval'?
These directives are required due to our technology stack:
| Directive | Reason |
|---|---|
script-src 'unsafe-inline' |
Livewire/Alpine.js - Livewire injects inline scripts for reactivity. Alpine.js uses @click and x-data which require inline execution. |
script-src 'unsafe-eval' |
Livewire's DOM diffing - Uses eval() for morphdom diffing algorithm. Alpine.js uses eval() for x-data expressions. |
style-src 'unsafe-inline' |
Blade/Livewire - Dynamic style bindings, FilamentPHP components inject inline styles for theming. |
Mitigations in place:
- XSS protected via Blade's automatic escaping (
{{ }}) - Input validation on all user inputs via FormRequest
- Only Stripe's external scripts are whitelisted
- CSRF protection on all mutating requests
- HttpOnly cookies prevent JavaScript access to session
Future improvement: Consider using nonce-based CSP with Laravel's @cspNonce directive when migrating away from inline dependencies.
CORS Configuration
// config/cors.php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => array_filter(explode(',', env('CORS_ALLOWED_ORIGINS', ''))),
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'X-CSRF-TOKEN'],
'supports_credentials' => true,
CORS Security:
- No wildcard default - Must explicitly configure allowed origins in
.env - Configure
CORS_ALLOWED_ORIGINSwith comma-separated origins - Example:
CORS_ALLOWED_ORIGINS=http://tenanto.local,http://*.tenanto.local - Use
CORS_ALLOWED_PATTERNSfor wildcard subdomain matching
Environment Security
Secrets Management
Never commit secrets to git:
.env
.env.local
.env.production
*.pem
*.key
Use environment variables:
# Good - from environment
DB_PASSWORD=${DATABASE_PASSWORD}
# Bad - hardcoded
DB_PASSWORD=secret123
File Permissions
# Application files
chmod -R 755 /var/www/tenanto
# Writable directories
chmod -R 775 storage
chmod -R 775 bootstrap/cache
# Environment file
chmod 600 .env
Database Security
-- Principle of least privilege
CREATE USER tenanto_app WITH PASSWORD 'xxx';
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tenanto_app;
-- No DROP, CREATE, ALTER for app user
Logging & Monitoring
What to Log
| Event | Level | Context |
|---|---|---|
| Login success | INFO | user_id, ip |
| Login failure | WARNING | email, ip |
| Password reset | INFO | user_id |
| Permission denied | WARNING | user_id, resource |
| Cross-tenant access | CRITICAL | user_id, attempted_tenant |
| Payment failure | ERROR | tenant_id, error |
Log Format
// Structured logging with tenant context
Log::channel('daily')->info('User logged in', [
'user_id' => $user->id,
'tenant_id' => $user->tenant_id,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
Monitoring Alerts
Set up alerts for:
- Multiple failed login attempts
- Cross-tenant access attempts
- Payment failures
- Error rate spikes
- Response time degradation
Security Checklist
Pre-Deployment
-
APP_DEBUG=false -
APP_ENV=production - All secrets in
.env(not hardcoded) - HTTPS configured
- Security headers enabled
- Rate limiting configured
-
composer auditpasses -
npm auditpasses - All tests pass (especially tenant isolation)
Code Review
- Authorization check on every endpoint
- Input validation on all user input
- No raw SQL with user input
- Sensitive data not logged
- Error messages don't leak details
- File uploads validated (type, size)
Infrastructure
- Firewall configured (only 80, 443 open)
- SSH key-only authentication
- Database not publicly accessible
- Redis password protected
- Backup encryption enabled
- Log retention configured
Ongoing
- Weekly dependency updates review
- Monthly security audit
- Quarterly penetration testing
- Annual security training
Incident Response
Security Incident Steps
- Detect - Monitoring alerts, user reports
- Contain - Isolate affected systems
- Investigate - Review logs, identify scope
- Remediate - Fix vulnerability
- Recover - Restore services
- Document - Post-mortem report
Contact
For security issues, contact the Tenanto security team immediately.
Responsible Disclosure: Please report security vulnerabilities privately before public disclosure.
Security Updates
November 2025 Security Hardening
The following security improvements were implemented:
| Category | Change | Impact |
|---|---|---|
| Headers | Added CSP, Permissions-Policy, Referrer-Policy to nginx | Prevents XSS, clickjacking, feature abuse |
| Headers | Stricter headers for admin panel (DENY framing, no-cache) | Enhanced admin security |
| SQL | Created QueryHelper::likeContains() for safe LIKE queries |
Prevents LIKE injection attacks |
| CORS | Removed wildcard default in config | Prevents CORS misconfiguration |
| API | Set 7-day token expiration default | Limits token exposure window |
| Config | Removed insecure license secret default | Forces explicit configuration |
| Config | Stronger demo password default | Reduces brute-force risk |
Files Modified:
docker/nginx/sites/tenanto.conf- Security headersapp/Support/Helpers/QueryHelper.php- New LIKE escaping helperapp/Http/Controllers/Api/V1/ProjectController.php- Safe searchapp/Http/Controllers/Api/V1/TaskController.php- Safe searchapp/Domain/Licensing/Services/LicenseService.php- Safe searchconfig/cors.php- No wildcard defaultconfig/sanctum.php- Token expirationconfig/licensing.php- No insecure default.env.example- Updated secure defaults
Related Documentation
- Multi-Tenancy Architecture - Tenant isolation implementation
- Authorization & Permissions - RBAC and policies
- API Documentation - API authentication and rate limiting
- Deployment Guide - Production deployment security