Trust
This page is written for the engineer who has been asked to review Align before their company buys it.
It is organised around what you would go and test, and every claim states its fail direction: what happens when the thing that makes the claim true is absent, disabled, or never configured. A control described unconditionally when it is really conditional is the failure we are trying to avoid, because it contaminates the claims that are unconditionally true.
Where something is not true, or is true only of one deployment shape, it says so. There is a list of things we deliberately do not claim near the bottom. Read that one first if you are short of time.
We hold no certifications. No SOC 2 report and no ISO 27001 certificate. We won't tell you an audit is underway when it isn't. See Certifications.
The deletion commitment
A policy promising non-retention is only as good as the code that honours it. We enforce ours in the database instead, so application code cannot break it, including old application code still running during a rolling deploy. You can check it yourself without asking us; there are queries at the bottom of this page.
Captured message bodies are never retained
The connector capture buffer (sources.payload) records provenance only: tenant, platform,
source reference, timestamp. The message body is not kept. Three mechanisms enforce it, and
they were added together in migration 088_sources_no_raw_retention.sql:
| Mechanism | What it does |
|---|---|
A BEFORE INSERT OR UPDATE trigger | Overwrites payload with {} on every write, so a pod running older code physically cannot store a body |
A CHECK constraint, validated | Rejects any row where payload is not {} |
A retroactive UPDATE | Cleared every row that already existed when the migration ran |
The ordering matters and is deliberate. The trigger lands first, so pods still running the previous image keep succeeding rather than erroring during the rollout. The constraint is then added unvalidated, the scrub runs, and only then is the constraint validated. Adding it in its validating form up front would abort the migration on the rows already there; running the scrub first without the constraint in place would let a write land in the gap.
Fail direction: closed, twice over. Remove the trigger and the validated constraint rejects the write, loudly. Both would have to be dropped for a body to be stored.
Being precise about what does and does not protect that, because the obvious argument is wrong:
the gateway's boot-time schema guard compares rows in schema_migrations, not the database
objects themselves. So it catches a database that never ran the migration, and it would not
catch an operator who dropped the trigger and the constraint afterwards, because the migration
row stays behind. That guard is also disabled by SCHEMA_CHECK_MODE=warn or =off, which the
chart exposes as a value. What actually protects this is that dropping both requires deliberate
ALTER TABLE access to your own database.
The same pattern, minus the trigger, forces import_suggestions.raw_content empty
(078). It needs no trigger because both writers already wrote an empty string before the
migration, so there was no older writer to coerce.
What this does not cover
Three places captured content still lives. We would rather list them than have you find them.
Historical import batches. import_job_batches.items_json holds the full fetched items,
including Slack and Teams threads and Zoom transcripts, so that a long import can resume after
a failure. A cleanup job deletes them 33 days after the job last changed.
Two conditions on that number. The clock runs from the job's last update, not from when it completed, and any later touch restarts it. And a cancelled job is never purged: the cleanup covers jobs that completed or failed, and the reaper that ages out stuck jobs covers jobs that are running or pending. A job you cancel matches neither, so its content is retained until someone deletes it by hand.
Raw text on the decision record. decision_json.raw_text is written at capture and cleared
once extraction finishes. The clear runs in a finally block, so it survives an analysis
error, but the analysis itself is started without being awaited by the request handler. If the
process restarts between the write and the clear, that row keeps its text until a
reconciliation sweep finds it. The sweep runs every 15 minutes and clears any decision record
still holding raw text that was last written more than 60 minutes ago, so a restart can leave
text behind for up to about 75 minutes (for a tenant with fewer than 2,000 such rows). It is on by default and RAW_TEXT_SWEEPER=disabled
turns it off. It judges by age because the analysis records no "finished" state in the database.
The captured payload embedded on the decision record. Since 2026-07-26 the write path
stores the source identity only, and migration 087 cleared the records already written. That
guarantee is application code, not a database constraint: one helper strips the payload
before the row is embedded. Nothing in the database would stop a future write path from
embedding it again.
Fail direction for the import job and the embedded payload: open. A cancelled job or a new
write path that skips the helper leaves content in place, and nothing surfaces it. Raw text on
the decision record used to be in that list. The sweep above bounds it, and it fails open again
only if an operator sets RAW_TEXT_SWEEPER=disabled.
Deletion on request
Connector disconnect deletes that connector's stored OAuth credentials.
Per-user erasure and export are admin-mediated. An organisation admin endpoint exists for each (GDPR Articles 17 and 20), and a data subject cannot invoke either directly, so a request is fulfilled by an admin. Corrected 2026-09-24: until 2026-09-21 both endpoints queried a table that does not exist in our schema and failed at the database. They were fixed in ALI-1127, against the real schema, with integration tests that run against a real PostgreSQL rather than a mocked one.
Tenant offboarding is an operator-run tool, not a button. A deletion script removes one tenant's data from every tenant-scoped table. It runs as a dry run unless told otherwise, requires the tenant's name, an actor and a reason to execute, refuses while a legal hold is active, and counts any residue before it commits (ALI-1128). On Align cloud, a human at Align runs it; contract terms govern the timescale. On a self-hosted install the database is yours, so you can also simply drop it.
See Data Retention for the full table.
Where your data is
Align cloud runs in AWS eu-west-2, London. A named region, not a bloc.
Three honest asterisks:
- Model processing is not region pinned. A prompt sent to a model provider is processed wherever that provider processes it. If that matters to you, bring your own key or run a local model, both of which are supported.
- UI assets are served through a global CDN, and the WAF in front of it is pinned to
us-east-1, which is where that AWS service lives. So request metadata for cached static assets is not UK-only. No decision data passes through it. - A self-hosted install runs wherever you deploy it. The region above is a fact about our hosted environment, not a property of the software.
The API hostname resolves directly at the load balancer, so it does not pass through that WAF. We mention it because the unqualified word "WAF" in a controls list would read as "the API is behind a WAF", and one DNS lookup would disprove it.
Controls that fail closed
| Control | What it does | Fail direction |
|---|---|---|
| OAuth token encryption | AES-256-GCM, 12-byte IV, authentication tag verified on decrypt | Closed. The gateway refuses to start without its master key. Two named opt-outs exist for local development, AUTH_DEMO_MODE and ALIGN_ALLOW_PLAINTEXT_TOKENS. The chart renders the first and defaults it to false; the second appears in no chart or deployment file at all. So a hosted or Helm self-host deployment crash-loops rather than storing plaintext. Either opt-out being set explicitly is enough to permit plaintext, and both are reachable by an operator: gateway.authDemoMode is a chart value, and ALIGN_ALLOW_PLAINTEXT_TOKENS can be injected through the chart's gateway.extraEnv, so "not templated by the chart" is not the same as "not settable". Neither happens by default. Since ALI-1450 the gateway also refuses to boot with AUTH_DEMO_MODE=true unless NODE_ENV is development, test or unset, or ALIGN_ALLOW_DEMO_MODE_IN_PRODUCTION=true is set, so an accidental demo mode in any deployed environment crash-loops rather than authenticating without a credential. That override also permits plaintext token storage when no master key is set. The shipped local Docker Compose stack does set demo mode, and there tokens are stored unencrypted. |
| Encryption key rotation | Retired keys stay readable while new writes use the current key, with a re-encryption path | Closed. A malformed retired key fails at boot rather than being skipped. A fully retired key makes its tokens undecryptable rather than silently readable. Covered by tests. |
| Webhook signature verification | Each of the seven deployed connectors verifies inbound deliveries: HMAC for Slack, GitHub, Zoom and Linear, a shared secret for Jira and Confluence, and the Graph clientState value for Teams, which Microsoft does not HMAC-sign | Closed on all seven. An unconfigured secret rejects the delivery rather than accepting it unverified. The connector SDK's own helper returns true when no secret is set; every connector that uses it checks for the secret first, so that path is unreachable. Two connectors expose an explicit signature-skip for local development, and we would rather list both than round down: SKIP_SLACK_VERIFY, which the shipped Compose stack sets to true, and SKIP_GITHUB_VERIFY, which the Compose stack names and leaves false. Neither appears in the Helm chart or the deployed configuration, so neither is reachable in a hosted or Helm self-host deployment without an operator adding it. Two connectors in the repository are not deployed and are not covered by this row: mcp-datadog and mcp-github-actions. Neither is in the connector registry, the Helm chart or the build matrix, so neither is deployed or routable by the running product - their test scripts do run in CI, and either can be run locally, but no traffic in Align Cloud or a Helm self-host ever reaches them. mcp-github-actions carries a real, fail-closed HMAC webhook verifier (ALI-1136) - a property that matters if it is ever wired into the registry, and irrelevant until then, since nothing routes traffic to it. We name them rather than let "every connector" cover a directory you can read. (A tenth component, mcp-align, is deployed: it is Align's own MCP server for coding agents and receives no third-party webhooks, so this row does not describe it. Its authentication is a bearer token that rejects when unset.) |
| Session revocation and deactivation | is_active is re-read from the database on every authenticated request | Closed. Deactivating a user through SCIM ends their live sessions at the next request rather than at token expiry. The field is a required type on the internal call, so dropping it from a query is a compile error rather than a silent bypass. |
| PII redaction | Runs before every completion, streaming and tool call to a model provider, on by default | Closed for those paths, and only those. Only an explicit configuration flag turns it off. The embeddings path is not covered by this choke point. An embedding request is a call to a model provider, so we state the exclusion rather than let "every model call" imply it. |
| Tenant isolation | The tenant comes from the verified auth token; a client-supplied header can never override it for an authenticated request | Closed, and locked by a regression test. The database Row-Level Security layer underneath is a second backstop that only becomes operative once the application connects as the restricted non-owner role; see Trust & Security for exactly where that stands, including on our own hosted deployment. |
| Tenant-supplied model keys | You can bring your own provider key. It is encrypted at rest using PostgreSQL pgcrypto, with the key supplied per transaction | Closed at the route. Supplying a key while the master key is unconfigured returns an error rather than storing plaintext. Note this is a different mechanism from the OAuth token encryption above, and we do not claim AES-256-GCM for it. |
What we do not claim
Every line here is something a reasonable reader might assume from the section above, and each one would be wrong.
- We do not claim a GDPR Article 15 access endpoint. The export endpoint is built for Article 20 portability, and there is no separate Article 15 route. As of today we also do not claim that endpoint works: see the deletion section above.
- We do not claim the data-subject rights endpoints are self-serve. They are organisation-admin only, and admin-mediated.
- We do not claim fine-grained role-based access control on those endpoints. They check that the caller holds the organisation-admin role. That is a role check, not a permission lookup against a policy.
- We do not claim legal hold covers everything. It applies to decision snapshots. A decision with no snapshot is not protected by it, and audit records use a different mechanism (they are append-only and never purged, so they need no per-row hold).
- We do not claim every authentication event is audited. Failed and revoked-credential attempts are audited, and so are one-time-passcode logins and logouts. A successful single-sign-on login does not currently write a login audit record.
- We do not claim "no data ever leaves your environment". For Align cloud it plainly does. For self-host, model calls leave unless you run a local model.
- We do not claim end-to-end encryption. Traffic between services inside the cluster is plain HTTP by default. A default-deny network policy ships with the chart and is off by default.
- We do not claim SAML has been proven against a live identity provider. SAML 2.0 sign-in is built, and its library is a declared dependency of the gateway. It is tested in our test suite with signed assertions from a throwaway identity provider, and it has never been run against a live Okta or Entra tenant. It is off until an operator turns it on, and only sign-in started from Align (SP-initiated) works: sign-in started from the identity provider's own tile is refused on purpose. We would run a first SAML setup with you. Single sign-on via OpenID Connect is the path with live-tenant use behind it. See SAML sign-in.
- We do not claim that code enforces the Enterprise plan on SSO or SCIM. The plans page lists both under Enterprise. No plan or licence check sits in the SAML or SCIM routes, so a tenant on any plan that holds a SCIM token, or that has SAML turned on, can use them. Who gets them is a sales decision, not a gate.
- We do not claim a penetration test. None has been performed.
- We do not claim a rehearsed disaster recovery. Backups exist and a runbook states objectives, but no restore has been rehearsed, so those numbers are estimates from documented mechanics rather than measurements.
Certifications
We hold none, and we are not going to imply otherwise.
Our current route is ISO 27001, for two reasons that are about us rather than about the standard: it is the cheaper of the two at our size, and a UK company cannot have a UK practice sign a SOC 2 report, because only US-licensed CPA firms may. Much of the control work is shared between the two, so a later SOC 2 would not start from nothing.
We have not engaged a certification body. When we do, this page will say who and when. If a credential is a hard requirement for you, tell us and say whether it gates your evaluation or your production rollout, because those are very different asks.
Subprocessors, and how you hear about changes
The current list, with what each one processes, is at Subprocessors.
Our intended commitment is at least 30 days' prior written notice before we engage a new subprocessor, with a window for you to object on reasonable data-protection grounds.
Stated precisely, because this is a contractual promise and we would rather under-promise here than be caught over-promising: that wording sits in a data processing agreement that is still in legal review and not yet executable. It is not a term you have today, and we will not pretend otherwise until a solicitor has signed it off. The subprocessor page linked above currently commits only to reflecting changes there. If a notice period matters to your procurement, raise it and we will put it in your contract.
Security pack, on request
If your review needs paperwork, we send a pack on request. It holds a two-page overview, a controls review, the permissions each connector asks for, a pre-filled CAIQ and AI-CAIQ, and a proposed scope for a pilot. Each control claim in it is checked against our code, and it carries the same list of things we don't claim as this page. Claims about our AWS setup come from our infrastructure repository and are marked.
Email security@align.tech with your company name and what your review needs. We don't publish the pack, so that we know who has which version when something changes.
We can also send draft security terms and a data processing agreement. Those are drafts that are still with a solicitor, and they aren't executable yet.
Reporting a vulnerability
Our disclosure policy states what is in scope and how quickly you will hear back. The repository it lives in is private, so we send it on request rather than linking you to a page you cannot open.
For anything on this page, including data-protection questions: security@align.tech.
We do not publish a separate dpo@ address, because we have not appointed a statutory Data
Protection Officer. At our size the GDPR Article 37 appointment criteria do not apply, and an
address that routes to the same inbox would only imply a role that does not exist.
Verify it yourself
You should not take the deletion section on trust.
Two conditions before the results mean anything. These need direct database access, so they
are runnable on a self-hosted install and not on Align cloud, where you do not have the
database. And every table below has row-level security enabled, so running them as the
restricted application role without a tenant context set returns zero whatever the tables
contain. Connect as the database owner, or set app.current_tenant first. A zero from the
wrong role is the vacuous pass this page is trying not to hand you.
Only the first two are promised to return zero, and they are the two enforced by database constraints rather than by application code.
The third can legitimately return a number, and that is not a failed verification. It checks the raw text on the decision record, which is cleared by a background step rather than by a constraint. A process restart between the write and the clear leaves a row behind until the reconciliation sweep clears it, which takes up to about 75 minutes. Captures from the last hour or so that are still being analysed also count. That is stated in the deletion section above, and this query is how you measure it rather than take our word for the size.
The fourth is the cancelled-import-job gap and may well return a number too.
-- Capture buffer must never hold content. Expected: 0.
SELECT count(*) FROM sources WHERE payload <> '{}'::jsonb;
-- Import suggestions must never hold raw text. Expected: 0.
SELECT count(*) FROM import_suggestions WHERE raw_content <> '';
-- Decision rows still holding captured text in EITHER location. Expected: 0.
SELECT count(*) FROM decision_snapshots
WHERE (decision_json ? 'raw_text' AND decision_json->>'raw_text' <> '')
OR (jsonb_typeof(decision_json->'context'->'source') = 'object'
AND decision_json->'context'->'source' ? 'payload');
-- The gap we told you about: content held by cancelled import jobs, which nothing purges.
SELECT count(*) FROM import_job_batches b
JOIN import_jobs j ON j.id = b.job_id
WHERE j.status = 'cancelled';
The third query is the one worth running twice. An earlier version of it checked only the cleared field and would have reported an all-clear while a second copy of the message survived one level down in the same column. We publish the corrected form, and we mention the wrong one, because a verification query that cannot fail is worse than none.
# The trigger and the validated constraint, read from the database rather than from us.
psql -c "\d+ sources" | grep -A2 'Check constraints\|Triggers'
psql -c "SELECT conname, convalidated FROM pg_constraint WHERE conname = 'sources_payload_empty';"
convalidated must be t. A constraint left unvalidated would enforce new writes while never
having checked the rows already there, and nothing visible would say so.