Security

The real security mechanisms behind Selyf: sessions, CSRF, CORS, token encryption, data isolation, error handling, HTML sanitization, and AI request handling.

This page describes real, implemented controls, not aspirational claims. For the system-level picture these controls fit into, see Architecture. For rate limiting specifically, see Rate Limits.

Sessions & CSRF

Sessions

Sign-in state lives in a single encrypted, HttpOnly cookie (Secure in production, SameSite=Lax) -- there is no separate server-side session store to leak. Signing out or disconnecting ends it immediately.

CSRF protection

State-changing routes (disconnect, account deletion, sync, analyze, draft) require an X-CSRF-Token header matching a session-bound token, checked with a timing-safe comparison, plus an independent check that the request’s Origin header is one of the deployment’s configured web origins. This is a separate mechanism from the OAuth state parameter used during sign-in, and from CORS enforcement below -- three independent layers, not one repeated three times.

CORS

Cross-origin requests are accepted only from an exact, schema-validated origin list -- a wildcard origin is rejected at configuration-load time, before the server even starts, not just at request time.

Gmail access

Read-only scope

Exactly one Gmail OAuth scope is requested: gmail.readonly. Google enforces this boundary at the API level -- there is no application code path that can send, delete, or modify a Gmail message, not merely an absent feature.

Two separate Google credentials

The OAuth client used for sign-in and the GCP service account used to receive Gmail push notifications are distinct credentials with distinct, narrow permissions -- a compromise of one cannot be used to impersonate the other.

Encrypted token storage

The OAuth refresh token is encrypted at rest with AES-256-GCM, using a random initialization vector per encryption operation, before it is ever written to the database.

Pull, not push

Gmail notifications are received by pulling from a Google Cloud Pub/Sub subscription on an interval, not by exposing an inbound webhook URL -- there is no unauthenticated endpoint an attacker could send a forged "new mail" event to.

Data access

IDOR-safe queries

Every query that reads a thread, message, or analysis result is scoped to the authenticated session’s own user id at the database layer -- there is no request shape or parameter that can address another account’s data.

Closed error contract

Every error response is one of a small, fixed set of codes with a generic public message; internal detail (stack traces, query fragments, upstream provider text) never reaches a client. See Errors for the full set.

Content Security Policy

The API's own CSP is deliberately as strict as default-src 'none' and frame-ancestors 'none' -- it returns only JSON, so there is no first-party script, style, or frame for a policy to need to permit. The one exception is the human-facing status page at the API's own root path, which ships zero JavaScript.

Email content & AI

Allowlist-based HTML sanitization

Raw Gmail HTML is sanitized against an explicit tag/attribute allowlist before it is ever stored or rendered -- script, iframe, object, embed, form, and inline event handlers are absent from the allowlist entirely, not merely blocked by a denylist. Every link is forced to open with target="_blank" and rel="noopener noreferrer".

Sandboxed rendering

Sanitized email HTML renders inside a sandboxed iframe using srcDoc, isolated from the dashboard’s own styles and scripts. The sandbox permits allow-same-origin but never allow-scripts, so even a hypothetical sanitizer bypass still could not execute script.

Prompt-injection resistant by construction

Email content is placed inside an escaped, delimited data block in the AI request, separate from a fixed system prompt that never changes based on input -- never treated as an instruction. This is covered by an automated test suite, not just a design intention.

Independently validated AI output

Every AI response is checked against a strict schema on this server, separately from whatever validation the AI provider’s own SDK does -- a malformed or unexpected response is rejected, not passed through. An AI result also has no authority to act: it cannot send, delete, or modify anything.