This page provides technical and audit-ready detail on the security controls, compliance frameworks, and data handling architecture built into the Sentsai platform. For procurement questionnaires or additional documentation, contact security@sentsai.com.
Access model
Sentsai connects via OAuth 2.0 with delegated, read-only permissions authorized by a Microsoft 365 administrator. Admin consent is granted once per tenant rather than per user, so once an administrator approves Sentsai for the organization, anyone in your directory can sign in. An administrator who approves only for their own account is the sole user until that is widened. We record whether each signed-in user holds an administrator role and attribute every scan to them; restricting who may run one is done in Entra. We request only the minimum permissions required to run each analysis. We have no write access whatsoever. We cannot create, modify, or delete any object in your Microsoft 365 environment. Ever. The Microsoft OAuth access token is held exclusively in our server-side session store and is never written to the database or exposed to the browser.
User.Read.AllRead user profiles, assigned licenses, and account statusDirectory.Read.AllRead directory roles, group memberships, and tenant configurationReports.Read.AllRead usage reports - app activity, mailbox usage, sign-in frequencyAuditLog.Read.AllRead sign-in logs to identify dormant and inactive accountsYou can revoke Sentsai's access at any time from Azure AD Enterprise Applications in the Microsoft Entra admin center. Revoking access immediately invalidates our ability to run further scans. Raw Microsoft 365 user records are processed in memory during the scan and are not stored in a structured form; they appear only inside your encrypted findings report.
Authentication security
On every login, Sentsai cryptographically verifies the Microsoft-issued id_token using RS256 (RSA-SHA256) against Microsoft's published JWKS signing keys. The token signature, audience (client_id), and issuer are all validated. Signing keys are cached for one hour to avoid per-request network calls while still picking up Microsoft key rotations. A tampered or forged token is rejected with HTTP 400 before any session is created.
The login flow generates a cryptographically random 256-bit state token (secrets.token_urlsafe(32)) per session and stores it in an HttpOnly, path-scoped cookie. On callback, the received state is verified against the stored value using a constant-time comparison (secrets.compare_digest) to prevent timing attacks. A mismatch is logged as a CSRF attempt and rejected; the auth event is recorded in the audit log without a tenant ID.
After authentication, a 256-bit cryptographically random session ID (secrets.token_urlsafe(32)) is issued to the browser as an opaque HttpOnly cookie. The actual access token and tenant identity are stored server-side in Redis using SETEX (atomic write + TTL in one round-trip). In multi-worker deployments, all workers share the same Redis instance - sessions are consistent across the entire process fleet. Sessions default to 8 hours (SESSION_TTL).
Logout and account deletion call delete_session, which executes Redis DEL (O(1), atomic) to remove the session from the shared store immediately. Any subsequent request carrying the same session ID - from any worker - receives HTTP 401. The prior implementation stored access tokens in a long-lived cookie that persisted for 30 days; that pattern is eliminated; the legacy ms_access_token cookie is explicitly cleared on login and logout.
All Sentsai-issued cookies are set with httponly=True (not accessible to JavaScript), samesite="lax" (mitigates CSRF on state-mutating requests), and secure=True in production (HTTPS-only). The session cookie (sentsai_session_id) has an 8-hour max-age matching the server-side TTL. Cookies are explicitly deleted on logout and account deletion; the browser holds no durable authentication credential.
After a GDPR erasure or account deletion, the Microsoft tenant ID is hashed (HMAC-SHA256) and written to a tombstone table. Every subsequent login from the same Microsoft identity checks the hash before creating or updating a tenant record. A match confirms the prior erasure completed: the returning identity is provisioned as a brand-new account with no link to the erased tenant UUID or any of its data (GDPR erasure is not a service ban), the tombstone is consumed, and auth.login.reregistration_after_erasure is recorded in the audit log. Silent resurrection of erased data is impossible - there is nothing left to resurrect.
Data storage
Encryption & cryptography
Scan findings and generated reports are stored in the database using a custom SQLAlchemy TypeDecorator (EncryptedText) that transparently encrypts and decrypts the column value. The algorithm is Fernet symmetric encryption (AES-128-CBC with PKCS7 padding and HMAC-SHA256 authentication). A FIELD_ENCRYPTION_KEY environment variable holds the key; if absent, findings are stored as plaintext and a startup blocker fires in production.
At every application startup, Sentsai performs a live encrypt + decrypt round-trip on a known value using the configured FIELD_ENCRYPTION_KEY. A warning or successful import is insufficient - only a live round-trip confirms the key is valid and the TypeDecorator will actually protect data at write time. If the self-test fails in production (ENVIRONMENT=production), startup is aborted with RuntimeError - the application refuses to handle tenant PII without confirmed encryption.
The scans table includes an encrypted_at_rest boolean column that records whether field-level encryption was active when that specific row was written (True = encrypted, False = key was absent at write time, NULL = legacy row before this column existed). This allows an operator audit to identify any historical rows written without encryption, even after key rotation or deployment changes.
Erased tenant records are tracked in a tombstone table so a later sign-in from the same identity is recognized as a re-registration (which is permitted, and provisioned as a brand-new account - see “Tombstone erasure integrity” above). The tombstone stores an HMAC-SHA256 digest of the Microsoft tenant GUID keyed with a TOMBSTONE_HMAC_KEY secret - never the plaintext GUID. Without the key, brute-forcing a dictionary of known MS tenant GUIDs would be feasible; the keyed HMAC closes that gap. An idempotent startup migration re-hashes any legacy plaintext entries.
Microsoft-issued id_tokens are cryptographically verified using the python-jose library against Microsoft's public JWKS keys. The verification checks the RSA-SHA256 signature, the token audience (must match the application client_id), and the issuer. A token with any tampered claim will produce a signature mismatch and be rejected with HTTP 400 before the login flow proceeds.
Strict-Transport-Security is applied to all responses when the request arrives over HTTPS, with max-age=63072000 (two years), includeSubDomains, and the preload flag. The header is conditionally set based on the request scheme to avoid locking out local development over HTTP. All communication between the browser, the Sentsai API, and Microsoft Graph uses TLS 1.2 or later.
Audit logging
Every state-mutating action on the platform is recorded in an append-only audit_logs table. Records include the action name, tenant identifier, resource type and ID, client IP address, user-agent string, timestamp, and a structured metadata field for financial and compliance context. Audit records are never deleted, even when a tenant account is deleted - the audit trail outlives the tenant row.
A BEFORE UPDATE trigger (protect_audit_log_update) is installed at application startup and enforces database-level immutability on the core audit fields: action, tenant_id, resource_type, resource_id, created_at, and action_metadata. Any attempt to change these fields raises a PostgreSQL exception - no DBA with direct database access can alter or fabricate the audit trail. IP address and user-agent may be set to NULL only (for GDPR anonymization), never changed to a different non-null value.
Every state-mutating endpoint queues its audit log entry in the same SQLAlchemy session as the action it records, then issues a single db.commit(). If the commit fails, both the action and the audit entry roll back together - no state change can complete without a corresponding audit record. Informational events (login, report view) use a best-effort commit that does not block the response on audit write failure.
Every authentication outcome is recorded, including: successful login (auth.login.success) with tenant ID, IP, and user-agent; failed login by cause (CSRF state mismatch, OAuth error, missing code, token exchange failure, token verification failure, missing tenant ID claim); and logout. Pre-authentication failures are recorded with tenant_id=NULL so they remain in the log without a joinable tenant record.
Audit log entries include the client IP address and user-agent at recording time, satisfying forensic traceability requirements (SOC 2 CC6.2). Both fields are automatically set to NULL after the tenant's configured retention period has elapsed - complying with GDPR storage limitation (Article 5(1)(e)) without deleting the underlying action record, which retains its evidentiary value.
Audited events include: auth.login.success, auth.login.failed.* (csrf / oauth_error / token_exchange / token_verification / missing_tid_claim / personal_account / app_not_in_tenant / consent_declined / admin_consent_required / no_code / no_id_token / no_access_token), auth.login.reregistration_after_erasure, auth.logout, auth.admin_consent.granted, auth.admin_consent.failed, scan.start, scan.complete, scan.history.view, report.view, report.download, report.pdf.download, payment.checkout.created, payment.renewal.checkout.created, payment.dev.unlock, payment.auto_renew.enabled, payment.auto_renew.disabled, payment.auto_renew.setup.created, payment.auto_renew.enrolled_via_setup, payment.webhook.subscription.started, payment.webhook.subscription.renewed, payment.webhook.subscription.auto_renewed, payment.webhook.subscription.refunded, payment.webhook.refund.failed, payment.webhook.subscription.disputed, payment.webhook.subscription.dispute_won, payment.webhook.subscription.dispute_lost, gdpr.export, gdpr.erase, gdpr.consent, gdpr.consent.withdraw, gdpr.retention.update, gdpr.marketing_preference.update, gdpr.summary.view, admin.console.view, account.delete.
Authorization & input validation
Microsoft grants admin consent once per tenant, not per user: once an administrator approves Sentsai on behalf of the organization, every account in that directory can sign in, and delegated Directory.Read.All does not narrow to the caller’s own privileges. Approval scope is itself a choice - Microsoft records consent granted for the organization (all principals) separately from consent granted by an administrator for their own account only, and the latter leaves every colleague seeing “Need admin approval”. To restrict it, set Entra › Enterprise applications › Sentsai › Properties › "User assignment required" to Yes and assign only the intended people under Users and groups. Approving Sentsai for a tenant is itself restricted by Microsoft to Global Administrator, Privileged Role Administrator, Cloud Application Administrator, Application Administrator, or AI Administrator; Cloud Application Administrator is the least privileged role sufficient for Sentsai, and roles such as User Administrator cannot approve applications at all. Once approved, Sentsai records but does not restrict ordinary use: each sign-in is checked against Microsoft’s stable role template GUIDs (not localized display names) to determine whether the user holds an administrator role. Destructive actions are the exception - account deletion, erasure, consent withdrawal and retention changes affect the whole organization and require an administrator. That result is held on the session, stamped on every scan, written to the immutable audit log, and printed as “Scan run by” on the web report and the PDF cover. A failed role lookup is recorded as unknown, never as a standard user. We do not notify anyone on your behalf.
Every scan endpoint verifies that the requested scan belongs to the authenticated tenant before returning data or modifying state. The tenant identity is resolved exclusively from the server-side session - never from a URL parameter or cookie value provided by the caller. A scan ID belonging to another tenant returns HTTP 403, not HTTP 404, to avoid information disclosure about whether other scans exist.
All scan_id path parameters are validated as RFC 4122-compliant UUIDs before reaching the database layer. Without validation, passing a string like "admin" or a path traversal sequence causes PostgreSQL to raise a DataError (invalid UUID representation) that surfaces as HTTP 500, potentially exposing internal error detail to the caller. Validation returns HTTP 400 with a generic message before any query is executed.
The scan rate limit check reads and increments the scan counter under a SELECT FOR UPDATE row-level lock on the tenant row. Without this lock, concurrent scan requests can all read count=0, all pass the gate, and all schedule background scans - effectively bypassing the free tier limit. The lock serializes the read-check-increment sequence so only one request at a time can pass the gate; subsequent requests see the incremented count.
A purchase activates a 12-month subscription via a Stripe webhook. The handler verifies the Stripe signature, checks the payment actually succeeded, and is idempotent: Stripe delivers webhooks at-least-once, so each event id is recorded and a duplicate delivery is a no-op. The tenant row is claimed with SELECT FOR UPDATE before the term is written, so two concurrent deliveries cannot both grant a term. A refund, or a dispute that reaches chargeback, revokes the term and re-locks the reports it unlocked; a dispute that is only an inquiry does not, and a dispute we win restores the term it revoked.
Scan execution and payment checkout are blocked unless the tenant has an active consent record (consent_accepted_at IS NOT NULL) matching the current terms version (CURRENT_TERMS_VERSION). When the terms version is bumped, existing tenants with stale consent receive a structured HTTP 403 with reason="terms_update_required" - enabling the frontend to surface a re-consent modal rather than a generic error. This gates all new processing on re-obtained, versioned consent.
PDF file paths stored in the database are validated before any file system operation by computing the canonical absolute path (os.path.normpath + os.path.abspath) and confirming it begins with the configured PDF_STORAGE_DIR. Any path that resolves outside the designated directory - whether via ../ sequences or symlink traversal - is silently skipped. The storage directory is itself an absolute path resolved at startup.
HTTP security headers
A Starlette middleware layer appends the following security headers to every response from the Sentsai API, regardless of the endpoint or HTTP method.
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadContent-Security-Policydefault-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'X-Frame-OptionsDENYX-Content-Type-OptionsnosniffReferrer-Policystrict-origin-when-cross-originPermissions-Policygeolocation=(), microphone=(), camera=()Payment security
All Stripe webhooks are validated using Stripe's HMAC-SHA256 signature scheme (stripe.Webhook.construct_event) before any payload is processed. The STRIPE_WEBHOOK_SECRET is required; an absent secret causes the webhook endpoint to return HTTP 500 and log an alert rather than processing unsigned events. A signature mismatch is logged and rejected with HTTP 400.
After signature verification, the tenant_id embedded in the Stripe checkout session metadata is compared against the scan's actual owner in the database. A forged or replayed webhook carrying a victim's scan_id but the attacker's tenant_id cannot unlock the scan - the ownership check fires before any state change. The mismatch is logged as a DEFENSIVE GUARD (C-2) BOLA alert and the webhook returns HTTP 200 so Stripe stops retrying.
Stripe delivers webhooks with at-least-once semantics - the same checkout.session.completed event may arrive multiple times on retry. A ProcessedStripeEvent table with a PRIMARY KEY on the Stripe event ID deduplicates deliveries at the database level. A duplicate event is detected before any state change is made; a subscription term is granted exactly once even under concurrent delivery or network retries.
The tenant and scan rows are loaded with SELECT FOR UPDATE in the webhook handler before any grant decision. Two simultaneously delivered webhook events cannot both read the pre-payment state and both activate a term - the second request blocks on the lock until the first commits, then reads the updated state, and the event-ID primary key rolls back any duplicate that slips past the check.
StripeError exceptions are logged with full detail internally (including Stripe request IDs and error codes) but the client-facing HTTP response always returns a generic message. The str() representation of a StripeError may contain SDK error chains and infrastructure identifiers that reveal deployment context to an attacker who can read API responses.
Payment card data is never processed or stored by Sentsai. All card handling occurs on Stripe's infrastructure. Sentsai receives from Stripe only a payment confirmation, the Stripe customer reference, and the checkout session metadata it originally supplied. The email address of the signed-in person who starts a payment is passed to Stripe, falling back to the admin of record, and only after consent has been verified - payment initiation is gated on a recorded consent record.
Compliance
Sentsai is not SOC 2 audited and holds no third-party security certification. Where a control below cites a SOC 2 criterion, that names the standard the control was designed against, not an attestation we have obtained. GDPR and CCPA/CPRA describe our actual legal position.
Sentsai acts as a data processor under GDPR Article 28. Implemented controls include: versioned consent recording with IP timestamp, structured data portability export (GET /gdpr/export, Content-Disposition: attachment), right to erasure with cascading hard-deletion (DELETE /gdpr/erase), consent withdrawal independent of erasure (DELETE /gdpr/consent), configurable retention periods (30–365 days) with automated nightly enforcement, and an immutable audit log protected by a PostgreSQL trigger. Our DPA is available at /legal/dpa.
As an Ontario, Canada company, PIPEDA applies directly. All 10 fair information principles are implemented: informed, versioned consent before processing; stated and limited collection purposes; configurable retention maximum (default 90 days); no sale of data; access rights via /gdpr/export; and a dedicated privacy contact at privacy@sentsai.com.
For California-based clients, Sentsai acts as a Service Provider under CCPA/CPRA. We do not sell, share, or use your data for cross-context behavioral advertising or beyond the stated business purpose. Our CCPA Service Provider Addendum is available at /legal/ccpa.
Support and operations use an internal read-only console reachable only over a private network and bound to the operator's authenticated identity. It shows account records (administrator contact and subscription state), billing records, and de-identified report aggregates; it is built so it cannot display the names or email addresses of directory users, nor the text of any finding. Every administrative view of a customer account is written to the immutable audit log as admin.console.view under that customer's tenant, with the viewer's identity - so it appears in the customer's own GDPR export. To see actual report content we ask the customer to share their report; there is no browse-everything mode.
Every state-mutating action (login, logout, scan start, scan complete, report unlock, GDPR erasure, consent change, payment) is recorded in an append-only audit log. A PostgreSQL-level BEFORE UPDATE trigger enforces immutability on the core fields - no DBA with direct database access can silently alter or delete audit entries. IP addresses and user-agents are included and anonymized only after the retention window, not deleted.
Data subject rights
All data subject rights are enforced at the API layer and available to authenticated users from the Account Settings panel or directly via the endpoints below. Each right is implemented as a dedicated endpoint with atomic audit logging.
GET /gdpr/summaryReturns a machine-readable inventory of all personal data held: tenant profile, scan records, PII categories, retention configuration, and a directory of rights endpoints.GET /gdpr/exportFull JSON export of all data held including scan findings, generated reports, and the audit log for this tenant (with IP/UA where not yet anonymized). Response includes Content-Disposition: attachment so browsers prompt a file download.DELETE /gdpr/eraseRequires a Microsoft 365 administrator: the action erases every scan for the tenant, not only those of the person requesting it, and under the DPA the Controller is the organization rather than any individual employee. Cascading hard-deletion of all non-billing scan rows. Billing records (unlocked scans with a financial retention basis) have PII fields nulled, not row-deleted. The Microsoft tenant ID is replaced with a random sentinel and its HMAC hash is written to the tombstone table. Any stored payment-processor identifiers are nulled, and where a Stripe customer record exists (created only if you opted in to automatic renewal) it is deleted via the Stripe API, which detaches the saved payment method with it. Stripe retains the underlying payment records under its own legal retention obligations, which we cannot and do not override. All changes committed atomically with the audit record.DELETE /gdpr/consentClears consent_accepted_at and terms version so the tenant must re-consent before running further scans or initiating payments. Does not trigger erasure - withdrawal is possible without requesting data deletion, consistent with Art. 7(3)'s requirement that withdrawal be as easy to exercise as giving consent.PUT /gdpr/retentionSets the data retention period between 30 and 365 days. Scan PII fields (findings_json, llm_report, pdf_path, aggregate stats) are automatically nulled by the nightly retention job for scans older than the configured period. Audit log IP/UA are anonymized on the same schedule.DELETE /account/deleteRequires a Microsoft 365 administrator: it destroys the entire account for the organization, including scans and paid reports belonging to colleagues. Hard-deletes the Tenant row (triggering cascade behavior on linked scans that have no financial retention basis). Billing scan rows are de-linked (tenant_id set to NULL) rather than deleted, preserving financial records without retaining the PII-bearing tenant row. Session is revoked and both cookies are cleared. A tombstone is written.Infrastructure & container security
The frontend container runs as the unprivileged node user, not root. The Dockerfile executes chown -R node:node /app before switching to USER node - ensuring the application owns its files under the least-privilege OS user. A containerised process running as root that achieves a breakout would have host-level privileges; the non-root USER directive significantly reduces that blast radius.
In production, session state is stored in Redis rather than an in-process Python dict. An in-process store in a multi-worker deployment (gunicorn + uvicorn workers) means a session created by worker A does not exist in worker B - causing legitimate authenticated sessions to receive HTTP 401 on approximately (1 - 1/N) of requests. Redis SETEX is atomic: write and TTL are set in one round-trip with no race between SET and EXPIRE.
An APScheduler job runs every 24 hours (offset from startup) to null PII fields on scans that have exceeded each tenant's configured retention period, and to anonymize IP address and user-agent on audit log entries past that window. The job runs in a thread pool executor (asyncio.to_thread) so it does not block the FastAPI event loop. Session cleanup uses a dedicated database session created and closed within the job.
Cross-Origin Resource Sharing is configured to allow credentials and requests only from the configured FRONTEND_URL environment variable - a single explicit origin, not a wildcard. The Content-Disposition header is explicitly listed in expose_headers because the Fetch spec does not treat "*" as a wildcard for credentialed responses and the GDPR export download requires that header to be visible to JavaScript.
Development endpoints that bypass payment or issue a session without sign-in are disabled in production. The app refuses to start unless its environment is named explicitly, so a missing or misspelled setting stops the deployment rather than defaulting to the permissive behavior. Session cookies are set Secure on every environment served over HTTPS.
Blocking database operations within background tasks (retention cleanup, scan completion) run in a thread pool executor via asyncio.to_thread rather than directly on the event loop. This prevents long-running synchronous DB queries from stalling the FastAPI event loop and blocking all concurrent HTTP requests - maintaining responsiveness under scan load.
Sub-processors
We use a minimal set of sub-processors. Customers are notified of any additions or replacements 30 days in advance as required by our DPA. Payment card data never transits Sentsai infrastructure - Stripe handles all card processing directly.
Responsible disclosure
We take security reports seriously. If you have discovered a potential vulnerability in Sentsai, please disclose it responsibly by emailing security@sentsai.com.
Contact