Security
Technical security documentation for IT and compliance teams evaluating Sevrel. This page details the cryptographic primitives, network architecture, access controls, and hardening measures that protect your organization's data.
1. Architecture
Sevrel uses a hybrid zero-trust deployment model that separates the web application, API backend, and LLM inference across independent trust boundaries. No single component compromise grants access to the full pipeline.
Data Flow
Browser (HTTPS)
→ Cloudflare Pages (static export, no server-side rendering)
→ FastAPI backend (Railway, managed PaaS)
├── Railway PostgreSQL (TLS-enforced, provider-managed encryption at rest)
├── Egnyte MCP (document search & retrieval, org-level OAuth)
└── Enterprise AI provider (HTTPS, API-key authenticated)
Tiered model routing: fast / standard / deep
Built-in web search (server-side, no third-party search API)Trust Boundaries
- Frontend → Backend: HTTPS with CORS restricted to explicit origins. Session cookies with HttpOnly Secure SameSite=Strict. No secrets in frontend — only NEXT_PUBLIC_* environment variables (non-sensitive).
- Backend → LLM: HTTPS to our enterprise AI provider. Every request authenticated with a server-side API key. Key validated at startup — the application will not start without it.
- Backend → Database: TLS-encrypted PostgreSQL connections (ssl=require enforced in asyncpg connect args). Credentials injected at runtime via DATABASE_URL environment variable — never committed to source control.
- Backend → Egnyte: Per-user OAuth tokens with encrypted storage. All API calls scoped to configured base path with traversal protection. Per-user token isolation prevents cross-tenant document access.
Key Design Decisions
- Enterprise AI with data protection: AI processing uses our enterprise AI provider, which does not use API data to train models. Only the minimum document context required for each query is sent. Tiered model routing ensures cost-efficient processing.
- Static frontend export: No server-side rendering eliminates SSRF and server-side template injection attack surface on the frontend.
- No data training: Your documents are never used to train or fine-tune any model. The LLM weights are frozen — your data cannot influence model behavior for other customers.
2. Authentication & Identity
Sevrel delegates identity management entirely to Microsoft Entra ID (Microsoft Entra ID). We do not store or manage passwords for SSO users.
Microsoft Entra ID Token Validation
When a user signs in, the frontend obtains an Microsoft access token via MSAL and sends it to POST /api/auth/azure with a Authorization: Bearer header. The backend performs full cryptographic validation:
| Signature Algorithm | RS256 (RSA public key from Microsoft JWKS) |
| JWKS Resolution | 4-source fallback: tenant-specific → issuer discovery → common tenant → organizations tenant |
| JWKS Cache | Per-URL cache, 3,600s TTL, max 50 entries with LRU eviction, thread-safe (prevents TOCTOU races) |
| Audience Check | Must match AZURE_CLIENT_ID or api://{AZURE_CLIENT_ID} |
| Issuer Whitelist | Only https://sts.windows.net/ and https://login.microsoftonline.com/ accepted |
| Tenant Enforcement | When configured with a specific tenant GUID, rejects tokens from other tenants |
| Clock Skew Leeway | 5 seconds (OIDC standard) |
| SSRF Protection | Tenant ID validated against UUID regex before URL interpolation; jwks_uri domain must match Microsoft hosts |
| Token Size Cap | 8,192 characters max (prevents DoS via oversized tokens) |
User Provisioning
Users are matched by Microsoft Object ID (oid claim) — an immutable identifier that survives email and display name changes. On first login, a Sevrel account is automatically provisioned. Email domain allowlists can restrict which organizations are permitted. Domain checks are re-enforced on every Microsoft login to catch email changes from the identity provider.
Timing Attack Mitigation
All authentication paths execute in constant time regardless of whether the user exists. For password-based flows, a dummy bcrypt hash is verified on user-not-found to prevent account enumeration via timing side channels. Admin email comparisons use hmac.compare_digest() with length padding to eliminate timing oracles.
3. Session Management
Cookie Format & Signing
Sessions use a 4-part signed cookie: accountId.expiresAt.sessionId.signature
| Signing Algorithm | HMAC-SHA256 |
| Key Derivation | HKDF-SHA256 from SECRET_KEY (≥256-bit). Salt: 'sevrel-session-hmac-v1', Info: 'session-cookie-signing'. Produces a 32-byte domain-separated key unique to session cookies. |
| Signature Verification | Constant-time comparison via hmac.compare_digest() — performed BEFORE any other validation to prevent timing leaks |
| Cookie Length Cap | 300 characters max (prevents DoS via oversized HMAC input) |
| Expiry Cap | 30-day maximum — even a correctly signed cookie is rejected if expiresAt exceeds this cap |
| Account ID Validation | Must be valid UUID format (prevents injection in downstream database queries) |
Cookie Attributes
| HttpOnly | true — cookie is inaccessible to client-side JavaScript |
| Secure | true in production — transmitted only over HTTPS |
| SameSite | Strict in production (Lax in development) |
| Max-Age | 604,800 seconds (7 days) |
| Domain | Not set — browser defaults to exact request domain, preventing subdomain leakage |
| Path | / |
Server-Side Session Revocation
Every login creates a row in the user_sessions table recording the session ID, user ID, IP address, user agent, creation time, and expiration. On every authenticated request, the backend verifies the session has not been revoked. Administrators and users can invalidate all active sessions across all devices via POST /api/auth/logout-all. Logout sets revoked_at on the session record.
Sliding Session Refresh
When a session passes 50% of its lifetime, the backend automatically re-issues a fresh signed cookie with a new session ID and records it in the database. This prevents active users from being logged out while maintaining a tight absolute expiry window. Refresh only occurs on successful responses (HTTP < 400) and is skipped if the handler already deleted the cookie (logout flow).
4. Encryption & Key Management
In Transit
| TLS Versions | TLS 1.2 and TLS 1.3 only — all older protocols disabled |
| Cipher Suites | ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-GCM-SHA256, ECDHE-ECDSA-AES256-GCM-SHA384, ECDHE-RSA-AES256-GCM-SHA384, ECDHE-ECDSA-CHACHA20-POLY1305, ECDHE-RSA-CHACHA20-POLY1305 (server-preferred) |
| HSTS | max-age=63072000; includeSubDomains; preload (2-year STS, preload-list eligible) |
| Session Tickets | Disabled (ssl_session_tickets off) — prevents ticket-based resumption attacks |
| OCSP Stapling | Enabled with verification |
| Database TLS | Enforced via ssl='require' in asyncpg connect_args, applied even when the provider strips sslmode from the connection URL |
At Rest
| OAuth Token Encryption | Fernet (AES-128-CBC + HMAC-SHA256, authenticated encryption with associated data) |
| Key Derivation | HKDF-SHA256 from SECRET_KEY. Output: 32 bytes. Salt: configurable via FERNET_SALT or fixed domain-separation salt. Info: 'fernet-encryption-key-v2' |
| Token TTL Enforcement | Optional TTL parameter on decryption — access tokens rejected after 2 hours regardless of encryption validity |
| Key Rotation | reset_fernet() clears cached key instance, enabling SECRET_KEY rotation without restart |
| Database Encryption | Provider-managed encryption at rest (Railway PostgreSQL) |
| Startup Guard | RuntimeError raised if SECRET_KEY is empty at key derivation time — prevents silent use of a zero-entropy key |
Password Hashing
| Algorithm | bcrypt via passlib (CryptContext) |
| Work Factor | 12 rounds (4,096 iterations) |
| Verification | Constant-time via passlib internals |
| Auto-Rehash | Deprecation mode 'auto' — older bcrypt versions are re-hashed on successful login |
Internal JWT (Access Tokens)
| Algorithm | HS256 (HMAC-SHA256, symmetric) — hardcoded in code with a field validator that rejects any other value, preventing algorithm confusion attacks |
| Key | SECRET_KEY (required ≥32 characters) |
| Expiry | 60 minutes (configurable) |
| Claims | sub (subject), exp (expiration) — minimal claim surface |
5. Access Control
Role-Based Access Control (RBAC)
Three tiers with least-privilege defaults. New users are assigned viewer by default. The permission matrix is defined as Python frozenset constants — immutable at runtime, preventing accidental mutation or configuration drift.
| Permission | Viewer | Member | Admin |
|---|---|---|---|
| chat:use | ✓ | ✓ | ✓ |
| documents:read | ✓ | ✓ | ✓ |
| conversations:read / write | ✓ | ✓ | ✓ |
| calendar:read | ✓ | ✓ | ✓ |
| email:read | ✓ | ✓ | ✓ |
| documents:ingest | — | ✓ | ✓ |
| calendar:write | — | ✓ | ✓ |
| email:write | — | ✓ | ✓ |
| admin:accounts / roles / audit | — | — | ✓ |
Enforcement
- Permissions are enforced via FastAPI dependency injection on every endpoint — Depends(require_permission("admin:accounts")). No endpoint can be accessed without passing the permission guard.
- Permission denials are logged as security audit events with the user ID, requested permission, and correlation ID.
- Admin email bypass (for initial bootstrap) uses hmac.compare_digest() with equal-length padding to prevent timing side-channel attacks.
Multi-Tenancy Isolation
Organization-scoped data isolation via org_idforeign keys on core tables (users, conversations, documents, audit logs). All queries are scoped to the authenticated user's organization. Egnyte document access uses per-user OAuth tokens — one user's token cannot access another user's files.
6. Data Handling & Privacy
Document Processing
When Sevrel answers a question, it fetches document content from your storage vault in real time and processes it in memory to generate the response. Full document text is not persisted as source-of-truth. Conversation metadata (message text, document names, page numbers, and short excerpts used as citations) is stored in PostgreSQL. Embeddings used for retrieval are stored in pgvector and scoped per organization.
| PDF Extraction Cap | 2,000,000 characters max |
| XLSX Extraction Cap | 50,000 characters max |
| DOCX Decompressed Size | 100 MB max (ZIP bomb protection) |
| CSV/TXT Extraction Cap | 200,000 characters max |
| Vector Store | pgvector (PostgreSQL extension), stored within the application database. Embeddings are scoped per organization and encrypted at rest via the database provider. All retrieval honors row-level security. |
Conversation Storage
| Storage Format | JSONB column in PostgreSQL (messages_json) |
| Max Message Length | 128,000 characters per message |
| Max Messages per Conversation | 500 (oldest messages pruned on overflow) |
| Max Conversations per User | 2,000 |
| Row-Level Locking | SELECT ... FOR UPDATE prevents lost-update races on concurrent message appends |
| Title Sanitization | Control characters (0x00-0x1f, 0x7f-0x9f), Unicode direction overrides, and prompt delimiters stripped |
What We Do NOT Do
- We do not train or fine-tune models on your data
- We do not share your data with any third party
- We do not log document content to application logs — only metadata (file names, page numbers)
- We do not send your queries or documents to any AI provider outside the single enterprise AI provider we operate
Data Deletion
Users can delete individual conversations or all their data. Deletion removes conversation records, associated messages, and evidence metadata from PostgreSQL. Because document content is never persisted, there is nothing to delete on the document side — your files remain in your Egnyte vault, untouched.
7. Network & Infrastructure
Infrastructure Security
The backend application runs on Railway, a managed PaaS with automated security patching and network isolation. The database (PostgreSQL) is hosted on Railway with TLS-enforced connections and provider-managed encryption at rest. All external API communication (AI provider, Egnyte, Microsoft Graph) uses HTTPS with API key or OAuth authentication.
Cloudflare (CDN & Hosting)
The frontend is served via Cloudflare Pages with automatic HTTPS, DDoS protection, and global CDN distribution. All API requests from the frontend to the backend use HTTPS with session cookie authentication.
API Security
All external API communication is authenticated and encrypted:
| Enterprise AI provider | API key authentication over HTTPS, tiered model routing |
| Egnyte MCP | Organization-level OAuth tokens, proactively refreshed |
| Microsoft Graph | OBO token exchange, encrypted token storage at rest |
| Stripe | Server-side only, webhook signature verification |
API keys and OAuth tokens are never exposed to the frontend. All secrets are injected as environment variables at runtime and validated at application startup.
Database Connection Security
| TLS | Required (ssl='require' in connect_args) |
| Connection Pool | 5 connections, 10 max overflow, 30s timeout, 1,800s recycle |
| Pre-Ping | Enabled — validates connections before use to detect stale handles |
| Statement Timeout | 5 seconds — prevents runaway queries from blocking the pool |
| Slow Query Logging | Warning at ≥500ms, error at ≥2,000ms |
| Credential Scrubbing | Regex scan removes postgresql://[password]@ from all log output |
8. Rate Limiting & DoS Protection
Multi-tier rate limiting with per-category isolation. Each category has its own lock to prevent cross-category blocking (e.g., a chat flood cannot block authentication rate checks).
| Category | Limit | Window | Scope | Storage |
|---|---|---|---|---|
| Authentication | 15 req | 60s | Per IP | In-memory + PostgreSQL |
| Chat (LLM inference) | 60 req | 60s | Per user | In-memory |
| Read (list/browse) | 300 req | 60s | Per user | In-memory |
| Email send | 10 req | 60s | Per user | In-memory |
| Admin writes | 10 req | 60s | Per user | In-memory |
IP Normalization
IPv4-mapped IPv6 addresses (e.g., ::ffff:1.2.3.4) are normalized to their IPv4 form to prevent rate-limit bypass via address format tricks. Behind a reverse proxy, the rightmost IP from X-Forwarded-For is used (trusted proxy appends the real client IP last; leftmost values are attacker-controllable).
Memory Exhaustion Protection
Rate limit tracking dictionaries are capped at 100,000 keys. When exceeded, a forced cleanup evicts stale entries. This prevents memory exhaustion from a botnet hammering unique IPs. Authentication rate limits are additionally backed by PostgreSQL (atomic UPSERT), surviving worker restarts and shared across process instances.
Additional DoS Controls
| Request Body Limit | 10 MB — enforced at ASGI level by counting actual bytes during streaming (not just Content-Length header), defeating chunked-encoding bypass |
| Concurrent Streams | 5 per user max — prevents a single user from monopolizing LLM inference capacity |
| Stream Duration | 600 seconds absolute cap per SSE stream |
| Audit Offset Cap | 100,000 max — prevents full-table-scan DoS via unbounded OFFSET |
9. Input Validation
Chat Request Validation
| Messages | 1–100 messages per request |
| Message Content | 1–32,000 characters per message |
| Role Enforcement | Regex-validated to 'user' or 'assistant' only — prevents system role injection |
| Conversation ID | UUID format validated via regex |
| Attachments | Max 20 per request, 255-char filename, 200,000-char extracted text |
| File Upload | 20 MB max, extension allowlist enforced, streamed with running byte counter |
| Model Tier | Enum: 'fast', 'standard', 'deep' |
Content Security
- Document poisoning detection: All uploaded documents are scanned for prompt injection patterns, encoded payloads (base64, hex, percent-encoded), and suspicious metadata before processing.
- Text sanitization: Document text is sanitized before inclusion in LLM prompts — stripping control characters, Unicode direction overrides, and known injection patterns.
- Encoding exfiltration defense: LLM responses are scanned for encoded payloads; detected segments are redacted before reaching the user, with a security audit event logged.
- ZIP bomb protection: DOCX and XLSX files have decompressed size validated (100 MB max) before parsing begins.
- Path traversal prevention: All Egnyte document paths are validated against the configured base path. Invalid paths are rejected before any API call is made.
SQL Injection Prevention
All database queries use SQLAlchemy's parameterized query builder — no raw string interpolation. The ORM layer ensures values are always bound as parameters, never concatenated into SQL.
10. Security Headers
Applied on every response via nginx, including error responses:
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
| Content-Security-Policy | default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none' |
| X-Content-Type-Options | nosniff |
| X-Frame-Options | DENY |
| Referrer-Policy | strict-origin-when-cross-origin |
| Permissions-Policy | camera=(), microphone=(self), geolocation=(), payment=(), usb=(), interest-cohort=() |
| X-Permitted-Cross-Domain-Policies | none |
CORS
| Allowed Origins | Explicit HTTPS-only allowlist (no wildcards in production) |
| Credentials | true (session cookies sent cross-origin) |
| Methods | GET, POST, PATCH, DELETE, OPTIONS |
| Headers | Authorization, Content-Type |
| Wildcard Protection | If '*' is present in config AND credentials=true, wildcard is stripped at startup to prevent any-origin cookie access |
| Error Responses | CORS headers are applied even on 413/500/502 error responses (EnsureCorsOnErrorMiddleware) so browsers can read error messages |
Host Header Validation
TrustedHostMiddleware validates the Host header against a whitelist derived from CORS origins. This prevents Host header injection in OAuth redirect URIs and cache-poisoning attacks.
11. Audit Trail
Append-only audit_logs table. The application layer never issues UPDATE or DELETE against this table.
Recorded Events
- auth.login / auth.login_failed / auth.logout / auth.logout_all
- auth.login_via_azure / auth.profile_updated_from_azure
- role_change / admin_account_access / admin_account_disabled
- permission_denied (user ID, requested permission, correlation ID)
- chat_query (user, query text, documents accessed, duration)
- document_ingest / document_access
- email_token_decryption_failed (forensic trail for token compromise investigation)
- session_revoked / encoding_redaction
Correlation IDs
Every request is assigned a UUID correlation ID (from the X-Correlation-ID header if present and valid, or auto-generated). The ID is returned in the response header and attached to all audit events, enabling end-to-end request tracing. Correlation IDs are UUID-format-validated to prevent log injection via control characters.
Integrity & Resilience
| Column Truncation | Action (64 chars), query (2,000 chars), response summary (500 chars) — truncated at source to prevent database IntegrityErrors that would silently lose audit events |
| DB Unavailability Fallback | If the database insert fails, the event is written to structured stderr at ERROR level with sanitized content (newlines stripped to prevent log injection) |
| Query Limits | Max 500 results per query, offset capped at 100,000 to prevent full-table-scan DoS |
| Newline Sanitization | All audit fallback log messages have newlines replaced to prevent log injection attacks |
12. Vulnerability Management
All identified vulnerabilities are tracked in-code with unique IDs (e.g., VULN-971) and mitigated at the source. The codebase currently tracks 30+ identified and mitigated vulnerabilities covering:
- Timing side-channel attacks (HMAC verification, admin email comparison, account enumeration)
- Session management (revocation, sliding refresh, cookie signing)
- SSRF prevention (tenant ID validation, JWKS URL domain checks, issuer whitelisting)
- Memory exhaustion (rate limit dictionary caps, request body streaming, connection pool limits)
- Log injection (correlation ID format validation, newline sanitization, credential scrubbing)
- Content injection (document poisoning detection, prompt delimiter stripping, encoding redaction)
- Race conditions (thread-safe JWKS cache, session key derivation locks, row-level DB locking)
- Information disclosure (stack trace hiding, validation error sanitization, credential scrubbing in logs)
Startup Fail-Fast Validation
The application performs comprehensive security validation at startup and refuses to run if critical configuration is invalid:
- SECRET_KEY must be non-empty, non-default, and ≥32 characters
- SECURE_COOKIES=true is required when using PostgreSQL
- Cloudflare Access credentials are required when the LLM URL is production
- Wildcard CORS origins are stripped in production (prevents cookie leakage)
- Non-HTTPS CORS origins are filtered out when secure cookies are enabled
- Trusted hosts are auto-derived from CORS origins if not explicitly set
- JWT algorithm is locked to HS256 — any environment override is rejected
13. Responsible Disclosure
If you discover a security vulnerability, please report it responsibly by emailing security@sevrel.com. We take all reports seriously and will respond within 48 hours. Please include reproduction steps, affected endpoints, and potential impact assessment where possible.