1. Multi-tenant model
Azora is a row-level multi-tenant system. There is one application, one PostgreSQL database, and one process per node — and every customer-data table carries an indexed account_id column.
-- Every customer-facing table looks like this:
CREATE TABLE tasks (
id text PRIMARY KEY,
account_id text NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
title text NOT NULL,
...
);
CREATE INDEX tasks_account_id_idx ON tasks (account_id);
Every query that reads tenant data is routed through a helper that adds WHERE account_id = $tenant before sending SQL to Postgres:
// Webbased/src/server/tenantDb.ts
const rows = await tenantDb(session)
.select().from(tasks);
// → SELECT * FROM tasks WHERE account_id = $1
What we don't do: separate databases per tenant, "workspaces" inside one big shared table, folder-based permissions, or row-level Postgres policies (RLS). Application-layer scoping is simpler to reason about in code review, and the helper is the only path to data — there is no raw SQL surface for application code.
Cross-tenant access is impossible by code path
The only place that touches data without an account_id filter is the Control Plane (§2), which has no SELECT access to tenant rows by design. The CP queries an entirely different schema (accounts, billing_events, admin_audit_log) — vendor metadata, never tenant business data.
2. The Control Plane
The Control Plane (CP) is the vendor-only admin surface — used by Azora staff to provision tenants, handle billing, and respond to support requests. It is a separate route tree (/dashboard/control-plane), a separate role hierarchy, and a separate audit log (admin_audit_log). Tenants cannot reach it; vendor staff cannot reach tenant data through it.
Identity model
A CP admin is a user row with account_id IS NULL. That single column is the landlord marker. There is exactly one CP admin per environment by default, seeded by migration 0004_seed_cp_admin_and_cleanup.sql.
Two layered defenses gate CP access:
- The user row must have
account_id IS NULL (DB marker)
- If
CP_ADMIN_NAMES env var is set, the username must also be on the allowlist (env-side second factor; optional)
Both are evaluated server-side in deriveIsCpAdmin. The flag is stamped onto the session cookie and the JWT at login; subsequent requests check it via middleware.
What the CP can and can't do
| Capability | CP admin | Tenant admin |
| Provision a new tenant | Yes | No |
| Suspend or delete a tenant | Yes (logged) | No |
| Read tenant business data (tasks, CAPAs, etc.) | No | Yes (own tenant only) |
| Reset a tenant user's password | Yes — via the same login flow the user runs (no read access to hash) | Yes (own tenant) |
| View billing events | Yes | Own tenant only |
3. Authentication & sessions
Password hashing
Passwords are hashed with bcrypt (cost factor 10), never stored or logged in plaintext. Hash columns are never returned by API endpoints; the hash is consulted only inside the password-check helper.
Session cookies
Sessions are sealed with iron-session. The cookie is HttpOnly, Secure, SameSite=Lax, and includes session.isCpAdmin + session.accountId stamped at login. Cookies are sealed with SESSION_SECRET — rotating that secret invalidates every active session.
JWT API tokens
API access uses JWTs signed with HS256 against the same SESSION_SECRET. JWTs include a per-user revocation watermark (users.jwt_issued_at): logout, password change, suspension, or admin force-logout all bump the watermark, and any JWT issued before that timestamp is rejected.
// Effect: a stolen JWT is invalidated server-side
// without needing a per-token blocklist.
if (jwt.iat < user.jwt_issued_at) reject();
TOTP 2FA
Per-user TOTP secret with bcrypt-hashed backup codes. Two-state secret model (pending vs. active) prevents TOCTOU on enrollment — the secret only becomes "active" after the user verifies a code from it. TOTP secrets at rest are AES-256-GCM encrypted; see §4.
Username enumeration
Login responses are normalized: identical timing and identical error message regardless of whether the username exists. Failed-login rate-limit is per-username AND per-IP.
4. Encryption-at-rest
Sensitive columns are encrypted at the application layer with AES-256-GCM, keyed by HKDF-SHA256 derivation from SESSION_SECRET. The DB never sees plaintext.
// Envelope format stored in DB:
// "enc:v1:<base64-iv>:<base64-ciphertext-with-authtag>"
const dek = hkdf(SESSION_SECRET, info=column_name);
const iv = randomBytes(12);
const ct = aesGcmEncrypt(plaintext, dek, iv);
db_value = `enc:v1:${b64(iv)}:${b64(ct)}`;
Versioned envelope prefix (enc:v1:) gives forward compatibility for future cipher migrations — the helper picks the decryption path off the prefix.
What's encrypted
- TOTP secrets
- R2 / S3 storage credentials per tenant
- License keys
- OAuth client secrets and similar third-party credentials
What isn't
- Business data — tasks, CAPAs, vendors, etc. (Postgres-level encryption is at the disk layer, provided by the host: AWS RDS / DigitalOcean managed Postgres / your own LUKS volume on self-host.)
- Email addresses (used for login, indexing, search)
Why not encrypt everything: Application-layer column encryption breaks indexes, full-text search, joins, and any meaningful query. We encrypt the things a database breach would otherwise expose as credentials usable elsewhere. For business data, we rely on Postgres-layer encryption, tenant isolation, and audit logging.
5. Audit logging
Every CRUD operation on tenant business data is logged to the audit_log table with:
- Actor:
user_id (or 'system' for automation/cron)
- Tenant:
account_id
- Action:
create / update / delete
- Entity: table + row id
- Diff: before/after JSON, redacted on sensitive fields (passwords, secrets, encrypted columns)
- Timestamp, IP, user-agent
The CP keeps a separate admin_audit_log for its own actions (provisioning, suspension, billing changes). The two never mix.
Read-audit logging
Tenant-scoped reads on sensitive entities (auth tokens, audit log itself, secret-bearing settings) are logged separately. This is for the auditor question "did anyone view the change-control history before signing it?" — yes, you can answer it.
Retention
Audit log rows are immutable (no UPDATE or DELETE permitted from application code) and retained for the life of the tenant. On tenant deletion (§10), audit log rows are dropped via ON DELETE CASCADE in the same transaction as the rest of the data.
6. Compliance posture
ISO 13485
Azora is positioned as ISO 13485-ready: the QMS module ships with CAPA, NCR, change control, audit management, risk register (ISO 14971), traceability matrix, document control, and training records. Internal QMS uses Azora itself.
What we are not: ISO 13485 certified as of May 2026. Customer organizations may achieve their own ISO 13485 certification using Azora as the QMS tool of record.
21 CFR Part 11
Electronic signatures are implemented with intent attestation, timestamp, IP capture, and an immutable audit-log entry. Signed change control records cannot be modified after signing — only superseded with a new version.
SOC 2
SOC 2 Type 2 audit on the Q4 2026 roadmap. We do not currently have SOC 2 attestation. See roadmap →
HIPAA
Azora is not currently sold as a HIPAA-compliant system. We do not sign BAAs as of May 2026. Customers who need HIPAA workflows should evaluate carefully — the architecture supports it (audit logging, encryption, access controls) but the formal attestation path is not yet completed.
SSO / SAML / SCIM
On the Q3 2026 roadmap for the Enterprise tier. Not currently shipped. See roadmap →
7. May 2026 third-party audit findings
An external security audit was performed in May 2026. Eleven findings, all closed before this brief was published. Each finding has a documented fix in BUG_TRACKER.md (internal) and a corresponding commit + test in the codebase.
| # | Severity | Area | Status |
| 1 | Critical | JWT revocation watermark missing — stolen tokens valid until expiry | Closed |
| 2 | Critical | TOTP secrets stored unencrypted at rest | Closed (AES-256-GCM) |
| 3 | High | Username enumeration via login-error timing | Closed (timing normalized) |
| 4 | High | X-Forwarded-For trust depth not bounded | Closed (TRUSTED_PROXY_DEPTH env) |
| 5 | High | Audit-log read access not itself audit-logged | Closed |
| 6 | High | App-settings endpoint exposed encrypted secrets in response | Closed (server-side scrub) |
| 7 | High | Bcrypt cost factor 8 → 10 | Closed |
| 8 | Medium | CP admin allowlist had no DB-side fallback | Closed (DB marker is sufficient) |
| 9 | Medium | Per-user nonce CSP caused hydration flicker on streaming RSC | Closed (static nonce per build) |
| 10 | Medium | Login query ORDER BY non-deterministic for duplicate names | Closed |
| 11 | Low | Several test fixtures committed weak-but-real passwords | Closed (rotated, history rewritten) |
The full audit report and per-finding diff links are available on request — email sales@azorasolutions.com.
8. Threat model
What we protect against
- Cross-tenant data leak. Mitigation: row-level
account_id scoping, tenantDb() helper as the only data path, code review enforces this.
- Credential theft from DB dump. Mitigation: bcrypt for passwords, AES-256-GCM for secrets-at-rest. A dump alone yields no usable credentials.
- Stolen JWT replay. Mitigation:
jwt_issued_at watermark — logout/password-change invalidates server-side without a blocklist.
- Unauthorized CP escalation. Mitigation:
account_id IS NULL as the marker, with optional CP_ADMIN_NAMES env allowlist. Tenant admins cannot escalate by editing their own row.
- Inserted-by-attacker audit-log entries. Mitigation: app-code never UPDATEs or DELETEs audit log rows; DB role used by the app has no privilege for those operations on that table.
- Username enumeration. Mitigation: identical response shape and timing for "user not found" vs "wrong password."
What we don't protect against (out of scope)
- Compromised customer endpoint. If a tenant admin's laptop is keylogged, Azora cannot help. Use 2FA and SSO (Q3 2026) to limit blast radius.
- Insider threat at Azora. Vendor staff with database superuser access can read tenant data despite CP/tenant separation. Defense: limited number of staff with prod access, all access logged in
admin_audit_log, hardware MFA required. Not zero-knowledge.
- Physical hardware compromise. Disk-layer encryption depends on the host; on managed Postgres providers, it's enabled by default. On self-host, you manage this.
- Cipher breakthrough on AES-256. If AES-256 falls, the column-encryption envelope versioning lets us migrate ciphers, but we don't promise post-quantum readiness today.
- Supply-chain compromise of a dependency. Mitigation is partial:
pnpm lockfile, dependency review on every PR, no auto-merging dependabot. A targeted attack on a critical dep is still possible.
9. Self-host model
Self-host is available on Enterprise tier. Same codebase, same database schema, same deploy script. You hold the only Postgres database; we never see your data.
What you get
- The full Azora codebase as a deployable artifact (Next.js app +
deploy.sh + Drizzle migrations)
- Deploy targets a single VPS or any container host. Reference deployment is Ubuntu 24.04 + PostgreSQL 16 + Nginx + PM2.
- Schema migrations versioned in
drizzle/; we provide migration scripts for each release.
- Updates:
git pull && bash deploy.sh for minor versions; major versions ship a migration plan.
What you give up
- The Control Plane is hosted-only by default. On self-host, billing/provisioning is manual or through your own tooling.
- Auto-scaling: a self-host deploy is single-node by default. Multi-node Postgres + app-server fan-out is possible but on-you-to-configure.
- Outage recovery: we operate the hosted product 24/7; on self-host, your operations team owns runbooks.
Why self-host
Common reasons: data residency requirements, internal IT policy, regulated industries with explicit "data must stay in our network" clauses, or simply preference. We don't gate features on self-host vs hosted — same code, same modules.
10. Data lifecycle & deletion
On signup
One row in accounts, plus seed data inserted with that account_id. No external services touched. No data leaves the cluster.
While active
All operations stay within the tenant's logical boundary. Backups are encrypted at the storage layer (managed Postgres provider's encryption) and retained per the standard cluster policy (typically 7 days point-in-time, 30 days snapshot).
On suspension
Tenant data remains intact; access is denied at the auth layer. CP admin can re-activate. Default suspension period is 30 days before data is purged.
On deletion / cancellation
The accounts row is deleted. Every other table cascades:
account_id text NOT NULL REFERENCES accounts(id) ON DELETE CASCADE
This is a single transaction. After commit, no row carrying that account_id exists in the database. Backups are rotated out per the cluster's retention window — within 30 days of deletion, no backup contains the tenant's data either.
Data export
Available at any time, on any tier, free. CSV per table, ZIPped. Triggered from Admin → Data export in the dashboard. Export includes the audit log so you have your own record of operations.
11. How to engage us
This brief covers the questions security teams ask in the first 90% of their evaluation. For the last 10%:
- Send your security questionnaire. We've answered SIG, CAIQ, and a number of bespoke questionnaires. Email sales@azorasolutions.com — we typically return it within 2 business days with diff links to the relevant commits.
- Schema walkthrough. We'll open a screen-share and step through the Postgres schema, the
tenantDb() helper, the audit log structure, and any specific table you want to see. 30–45 minutes.
- Audit report copy. The full May 2026 third-party audit report is available on request to qualified buyers. No NDA — we just ask you to use it for your own evaluation, not republish it.
- Self-host demo. If you're evaluating self-host, we'll spin up a reference deployment and walk you through the deploy script, the migration story, and the operations model.
For everything else: security@azorasolutions.com. We answer.