Authio docs

Concepts

Security model

How Authio isolates tenants, encrypts data at rest and in transit, authenticates users and services, audits every state change, and contains incidents. Written for the questionnaire.

Authio is the auth platform for B2B SaaS, which means a single compromise of our infrastructure would be a compromise of every downstream customer's sign-in. We take that seriously. This page describes the controls in production today — not the roadmap, not the marketing pitch. Where a control is partial, on a defined runway, or deferred behind a documented trigger, we say so.

For SOC 2 evidence, see the controls matrix in authio_compliance/controls.md. For the Tier 0 pre-flight audit that backs the claims below, see authio_compliance/SECURITY_AUDIT_2026-05-20-tier0.md.

1. Tenant isolation

Every customer is a tenant. Every tenant owns one or more projects; every project owns users, organizations, sessions, audit events, FGA tuples, webhook endpoints, SSO connections, SCIM directories, and custom domains. Cross-tenant reads are prevented by three independent layers — any one of them on its own would be sufficient for SOC 2 Type II; we run all three.

1.1 Postgres Row-Level Security (the primary control)

Every tenant-scoped table in our schema — users, identities, sessions, organizations, memberships, audit_events, webhook_endpoints, sso_connections, scim_directories, fga_tuples, custom_domains, risk_decisions, org_policies, and 20+ others (30+ tables in total) — has FORCE ROW LEVEL SECURITY enabled.

Every connection sets two GUC session variables before running a query: SET LOCAL authio.project_id = '...' and (where relevant) SET LOCAL authio.tenant_id = '...'. Every RLS policy on every tenant-scoped table evaluates against those GUCs. A request for project A can never see project B's rows because Postgres, not application code, enforces the predicate.

The FORCE qualifier is load-bearing: even the DB owner role is subject to RLS. A bug in application code that forgot to set the GUC fails closed — the query returns zero rows, not the entire table. We treat any zero-row anomaly on a query that should have hit data as a misconfiguration alert.

1.2 Application-layer project scoping

Every API endpoint resolves the calling project from the bearer token (API key, JWT, session cookie, or SCIM token) before any store call runs. The store layer accepts the project ID as a structural parameter; SQL builders refuse to compile without it. This catches RLS-bypass attempts before they reach the database.

1.3 Dedicated infrastructure (Enterprise)

Enterprise tenants can opt in to a dedicated Postgres instance (AWS RDS, single-tenant). When enabled, the auth-core router resolves the tenant's connection pool per request: a request for a dedicated-DB tenant never touches the shared Postgres at all. Platform-wide tables (billing, signing keys, dashboard operators) remain shared. See tenant modes for the full state machine.

BYOK at the storage layer (one customer-managed KMS key per dedicated DB) is on the Enterprise roadmap. Today, all dedicated DBs encrypt under the authio-dedicated-kms-prod AWS-managed CMK; per-tenant CMK ships when a customer's MSA names it.

2. Encryption at rest

Postgres data files are encrypted with AES-256 at the storage volume layer (Railway-managed for the shared cluster, AWS KMS-managed for dedicated RDS instances).

Above the volume, the following high-value columns are wrapped in an envelope-encryption scheme: customer-pasted bearer tokens used during one-shot imports (Auth0 / WorkOS / Cognito migration credentials), customer-supplied SCIM bearer tokens, branded-email SES API material, webhook signing secrets, and SAML IdP metadata signing keys. Each row is encrypted with a per-row data encryption key (DEK) wrapped by a per-project key-encryption key (KEK); the KEK lives in AWS KMS and is never exfiltrated. Decryption requires both the row, the KEK ARN, and KMS kms:Decrypt permission on the platform's runtime IAM role.

Recovery codes are stored as Argon2id PHC strings (m=64 MiB, t=3, p=4) — not the recovery code itself. API keys are stored as SHA-256 hex digests of a 192-bit random secret; the deterministic hash lets us index lookups in constant time, and the 192-bit input space makes precomputation infeasible. WebAuthn challenges and magic-link tokens are stored as SHA-256 digests of 256-bit random values, with the plaintext on the client only.

3. Encryption in transit

Every public endpoint is HTTPS. Cloudflare terminates TLS at the edge with TLS 1.2 (legacy) and TLS 1.3 (preferred); we don't accept SSL 3.0, TLS 1.0, or TLS 1.1. HSTS is published with a one-year max-age and includeSubDomains.

Per-tenant custom domains (auth.acme.com) get their own Let's Encrypt certs auto-issued via Cloudflare for SaaS. Customers add a CNAME and an ownership TXT at their registrar; Cloudflare validates and issues. Certs renew automatically. See the custom-domain setup guide for the flow.

Origin connections from Cloudflare to Railway are HTTPS over the public internet (Cloudflare validates the origin cert). Service-to-service traffic inside the Railway private mesh runs over the private network with the controls described in §4.

4. Service-to-service authentication

Authio is multiple internal services — auth-core, billing, management-api, sso, scim, fga, webhooks, audit. Every cross-service /internal/* call between them runs over mutual TLS (mTLS) on top of Railway's private mesh.

  • A private RSA-4096 CA mints per-service ECDSA P-256 leaf certs valid for one year. The CA private key lives in AWS Secrets Manager (authio/internal-mtls/ca-private-key); no running service has read access.
  • Each receiver pins TLS 1.3, asks for a client cert at handshake, and runs a per-route CN allow-list. Even with a CA-signed cert, the SSO service's identity cannot call admin-impersonate routes that only management-api is authorised for.
  • Annual rotation runs through a dual-CA-trust overlap window so the order of leaf-cert updates is not time-critical. See authio_compliance/runbooks/mtls-cert-rotation.md.

During the 2026-05-21 cutover from the previous shared-secret scheme to mTLS, the receivers run a 7-day fallback overlap that accepts either the new mTLS handshake or the legacy secret. After the overlap closes, every receiver is mTLS-only. Full design rationale and threat model: authio_compliance/architecture/internal-mtls-design.md.

5. Secrets management

Secrets are classified by what controls them and where they run:

  • AWS Secrets Manager: the internal mTLS CA private key, the dedicated-DB master credentials, the KMS key ARNs for envelope encryption, and the SES branded- email IAM material. These are pulled at boot or on rotation; they never appear in env vars on a running service.
  • Railway service env vars: per-service runtime configuration (JWT signing key references, OAuth client IDs/secrets, internal mTLS leaf cert + key for this service, database DSNs to PgBouncer). Env vars are set via the Railway dashboard or GraphQL API by named operators; we don't auto-push them from CI.
  • Never in git: the .gitignore patterns block *.pem, *.key, and .env* outside known test fixture directories. A pre-commit hook scans staged content for AWS access keys, Railway tokens, and Stripe keys; CI runs a second pass on push.

6. Audit logging

Every consequential event in Authio — authentication attempts, risk decisions, organization changes, recovery requests, webhook deliveries, policy updates, API key lifecycle, SCIM sync events, dashboard-operator activity — is written to audit_events.

Events flow through an AWS SQS FIFO queue (one per-environment), with KMS encryption at rest and a dead- letter queue for poison messages. A producer in each service emits the event; the audit-worker drains the queue and writes the row. The FIFO ordering keeps per-{project, event-type}sequence stable, which matters for audit-stream consumers (Datadog, Splunk, Snowflake, S3) that key on monotonic cursors.

The audit_events table is range-partitioned by created_at at monthly granularity. The default retention is 30 days; partitioning means retention extension is an O(1) operation (AUTHIO_AUDIT_RETENTION_MONTHS), and shortening is DROP TABLE per old partition rather than a catastrophic vacuum.

Audit events are immutable: no service has UPDATE/DELETE on the table outside the partition manager. The platform's dashboard_operator role can view but cannot edit. Customers see their own project's events via the dashboard, the management-API, webhook subscriptions, or audit-stream destinations — see audit log.

7. Authentication factors

Authio is passwordless. We never accept or store a password hash. Supported factors:

  • WebAuthn passkeys (FIDO2): platform authenticators (Touch ID, Face ID, Windows Hello, Android biometrics) and roaming authenticators (YubiKey, Solo). Cross-device passkeys via iCloud Keychain and Google Password Manager. Origin-pinned via RPID — see WebAuthn RPID for custom domains.
  • Magic links: HMAC-signed, 256 bits of entropy from crypto/rand, SHA-256 hashed at rest, 5-minute TTL. Delivered through AWS SES with DKIM signing aligned to the platform domain (or the customer's domain when branded email is enabled). DMARC alignment is part of the standard SES setup; we publish a strict p=reject policy on authio.com.
  • OAuth: Google, Microsoft (consumer + AzureAD), Apple, GitHub. Each provider's redirect URI is exact-matched against the project's allow-list — no wildcard or prefix matches.
  • Enterprise SSO: SAML 2.0 and OIDC, with IdP metadata stored per connection. Customers self-serve via the SSO Setup Portal.
  • SMS OTP and email OTP: available as step-up and recovery factors. Twilio Verify for SMS; country allow-lists per tenant.

JWT signing uses Ed25519 (EdDSA) by default; RS256 is available for environments that can't verify Ed25519. Signing keys rotate every 30 days with an overlap window so outstanding tokens stay valid until natural expiry. The verify path on every Authio service and SDK explicitly pins the allowed algorithm — alg: "none" and cross-algorithm header tampering both fail closed.

7a. Login-CSRF defense (callback state binding)

Every Authio sign-in ends with the browser landing on a customer BFF callback (typically https://app.acme.com/api/auth/callback) carrying the access + refresh tokens in the URL. Without an additional check, an attacker who legitimately obtained an Authio access token could craft /api/auth/callback?access_token=<attacker's JWT>&refresh_token=<…>, lure a victim to click it, and silently log the victim into the attacker's account on the victim's browser. Any data the victim subsequently creates would land in the attacker's account — the canonical login-CSRF threat model.

Authio defends against this with a cookie-bound state nonce that round-trips through the sign-in ceremony, mirroring the OAuth 2.0state param best practice:

  1. The customer BFF's sign-in handler (e.g. createAuthioSignInHandler from @authio/nextjs ≥ 0.3.0) generates a 32-byte random nonce, sets it as an HttpOnly cookie authio_callback_state on the BFF's own origin (SameSite=Lax, 5-minute TTL), and forwards the same value to Authio as ?client_state_nonce=.
  2. Auth-core persists the nonce on the magic-link / OAuth state row (in the metadata jsonb bag) and on the callback redirect back to the BFF echoes it verbatim as ?client_state_nonce=… alongside the tokens.
  3. The BFF's callback handler (createAuthioCallbackHandler in the same SDK) reads the cookie, compares it to the URL value with a length-checked constant-time equality, and refuses any mismatch with error=csrf_state_mismatch. The cookie is cleared on success or refuse so a leftover from an abandoned sign-in can't be replayed.

Backwards compatibility. Theclient_state_nonce field is optional on the auth-core wire contract. Customers running pre-v0.3 SDKs (or hand-rolled BFFs) continue to sign in successfully; their callbacks simply don't carry the nonce, the callback handler logs a singleconsole.warn per request, and the legacy unprotected path runs. That keeps the v0.3 rollout safe to ship platform-wide while customers migrate at their own pace.

This control was shipped in response to finding G1 of the 2026-05-21 Authio×Daylight security audit. See authio_compliance/SECURITY_AUDIT_2026-05-21_authio-daylight.md for the original threat model and @authio/nextjs ≥ 0.3.0 for the migration guide. Apps still on 0.2.x are vulnerable until they upgrade and adopt createAuthioSignInHandler; the SDK emits an ops-visible warning until the migration is complete.

8. Authorization

Two layers, each independently useful:

Per-org role-based access control is built into every membership row. Roles are owner, admin, and member by default; customers can mint custom roles per project. Role-gated endpoints in the management-API and dashboard check the active membership before any state change.

Fine-grained authorization (FGA) is a Zanzibar-style tuple store. Customers model their own relations (document#viewer@user, folder#owner@group#member) and query the check/expand/list API at request time. Direct + userset traversal; per-store and per-model isolation. See FGA.

9. Org-level security policies

Customers configure organization-scoped security policies through the dashboard or the management-API; auth-core enforces them on every sign-in, refresh, and step-up. Supported policies:

  • Require SSO for a given email domain — users on that domain can't sign in via passkey or magic link if their org demands SSO.
  • Require MFA on sensitive ops; the risk engine triggers step-up automatically on high-risk decisions.
  • IP allow-list (CIDR-based, IPv4 + IPv6).
  • Country block-list (ISO-3166-1 alpha-2). See geo policy.
  • Session idle timeout and absolute max session lifetime, both per org.

Policy violations are emitted as auth.policy_violation events in the audit log with the policy code, IP, user-agent, and method. See the T2.3 ship report for the full policy engine design.

10. Vulnerability management

Dependencies are scanned by GitHub Dependabot on every repo (16 application services, 8 SDKs, plus the platform infrastructure repos). Critical findings open a CVE ticket with an SLA of 7 days to patch; high findings 30 days. The SBOM for each service is generated at image build time and retained for 90 days in CI artifacts.

Static analysis runs gosec on every Go service and eslint-plugin-security + npm audit on every Node service. Pre-commit hooks scan for hard-coded secrets. CI fails on any new finding above a configured baseline.

Penetration testing: we run an external pen test at least annually, scoped to the platform's public APIs and the dashboard. Findings flow into the same risk register that hosts dependency CVEs. Bug bounty program TBD; today we triage responsible-disclosure reports via security@authio.com within one business day.

Setting expectations honestly: at our current stage we run one pen test per year, not continuous. We publish each test's scope, summary findings, and remediation status with our SOC 2 Type II report — and we will scale this cadence with revenue. If your security questionnaire wants quarterly testing, ask sales and we'll schedule it as part of the contract.

11. Incident response

On-call rotation is published in authio_compliance/runbooks/incident-response.md. Detection sources: paging on Cloudflare 5xx spikes, Postgres CPU/IO, SQS DLQ depth, mTLS handshake-failure rates, JWKS publish failures, scheduled-job lag, and per-service healthchecks. Severities are P0 (sign-in down), P1 (degraded), P2 (single-feature broken).

Public status page: status.authio.com. Every component (auth-core, dashboard, hosted-UI, billing, SSO, SCIM, webhooks, audit) is independently sampled every 30 seconds and posted on the page.

Breach notification: customer-impacting security incidents are communicated by email to the project's owner contacts within 72 hours of confirmed scope (faster for direct-impact incidents). The notice includes affected data categories, timeline, the root cause analysis, and the remediation steps. We do not delay notification for "investigation completeness" beyond what is required to scope the impact — partial notifications with follow-ups are the default.

SLA targets: 99.9% monthly availability for the sign-in path (auth-core + JWKS + hosted-UI); 99.5% for the dashboard and management-API. Both are tracked on the status page and reflected in Enterprise contracts.

12. Compliance posture

  • SOC 2 Type II — controls active; first observation period closes 2026; auditor engaged.
  • GDPR / UK-GDPR — DPA available on request; sub-processor list in authio_compliance/vendor-list.md; data subject access and deletion runbooks under runbooks/data-export-on-request.md and runbooks/deletion-on-request.md.
  • HIPAA — BAA available for Enterprise customers; auth surface only (we do not handle PHI in the general path).
  • ISO 27001 / FedRAMP — on the roadmap; documented as triggers in the mTLS design doc and the migrate-off-Railway playbook.

Who to ask

Send security questions to security@authio.com. We respond to questionnaires (SIG Lite, CAIQ, custom) with reference to this page and the SOC 2 evidence package. For contractual commitments — DPA, BAA, custom security exhibits — route through your sales contact.