T
Tenanto
Documentation / Security

Security

Updated Jan 25, 2026

Security Guide

This document outlines security best practices and configuration for Tenanto.


Table of Contents

  1. Security Overview
  2. Tenant Isolation
  3. Authentication Security
  4. API Security
  5. Input Validation
  6. OWASP Top 10
  7. Security Headers
  8. Environment Security
  9. Logging & Monitoring
  10. 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

  1. Global Scopes - Automatically filter queries by tenant_id
  2. Policy Layer - Double-check tenant ownership on every action
  3. 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:

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:

Email Verification

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

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:

Required Middleware

All API routes use:

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


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 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:


OWASP Top 10

A01: Broken Access Control

Mitigations:

A02: Cryptographic Failures

Mitigations:

A03: Injection

Mitigations:

A04: Insecure Design

Mitigations:

A05: Security Misconfiguration

Mitigations:

A06: Vulnerable Components

Mitigations:

A07: Authentication Failures

Mitigations:

A08: Data Integrity Failures

Mitigations:

A09: Logging Failures

Mitigations:

A10: Server-Side Request Forgery

Mitigations:


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:

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:


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:


Security Checklist

Pre-Deployment

Code Review

Infrastructure

Ongoing


Incident Response

Security Incident Steps

  1. Detect - Monitoring alerts, user reports
  2. Contain - Isolate affected systems
  3. Investigate - Review logs, identify scope
  4. Remediate - Fix vulnerability
  5. Recover - Restore services
  6. 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:


Related Documentation


Additional Resources