Security model

Applications contain personal data, and sometimes sensitive answers. Here is what ATS does to protect them, and what it expects from your setup.

Authentication#

ATS never handles passwords. Something in front of it signs people in, and ATS verifies that it did:

  • Cloudflare: Cloudflare Access signs people in (for example with Microsoft 365). The Worker verifies the Access JWT's signature (against your team's keys), issuer and audience on every API call. A missing or misconfigured Access policy produces 401s, never open access. The workers.dev and preview hostnames are turned off so nothing bypasses Access.
  • Self-hosted: your reverse proxy signs people in and forwards their email in a header. ATS trusts that header only when a second header carries the shared PROXY_SECRET, compared in constant time. Someone who reaches the port directly can't claim an identity, which is also why the port must never be published.
  • Development sign-in (everyone is one fixed user) refuses to start unless explicitly in development mode.

Authorization: roles and sites#

Signing in only gets someone to the door. What they can do is managed inside ATS under Settings → Users & access; emails in BOOTSTRAP_ADMINS are always admins so you can get in the first time.

RoleCan
ViewerRead applications.
RecruiterRead; change stage, rating and job; write notes; manage jobs; reveal sensitive answers; email applicants; export CSV.
AdminEverything a recruiter can, plus users, settings, the audit log and deleting candidates.

Each person is also scoped to a set of sites (or all). Records outside their sites behave as if they don't exist, so their existence never leaks. These checks live in the core services themselves, so every route, including future ones, gets them.

Sensitive answers#

  • Answer keys listed under Settings → Sensitive fields (exact keys or prefix*; by default convicted* and work_auth*) are masked in the application view until someone reveals them.
  • Viewers can't reveal. Recruiters and admins can, and each reveal is written to the audit log with the keys revealed.
  • The same rule keeps sensitive values out of the full-text search index and the talent-pool matcher's text.

Audit log#

With the audit module on (the default), ATS records who viewed an application, revealed sensitive answers, downloaded a file, exported CSV, changed a stage, sent an email, deleted a candidate, or changed settings or users, along with when and from which IP. Entries store ids and the actor's email, never applicant answers, so the trail can outlive a deleted candidate. Entries are pruned after keepDays (default 730 days).

Self-hosted, set TRUST_PROXY=true so the IP comes from the right-most X-Forwarded-For entry your proxy adds; on Cloudflare it comes from CF-Connecting-IP.

Retention#

With the retention module on (the default), an hourly job deletes candidates nobody has touched for 24 months, or 36 months for people in the talent pool, and never auto-deletes people who were hired (your HR system owns them from there). All three values are adjustable in Settings → Modules. Inactivity is measured from the candidate's last activity. Large backlogs drain in batches over a few runs.

Right to erasure#

An admin can delete a candidate on request. Their uploaded files are removed first, then the candidate and everything attached to them (applications, files, notes, activity) and their search entries, in one database batch. The deletion itself is audit-logged by id.

The public intake endpoint#

/ats/ingest is reachable without a login because your web server calls it. It protects itself:

  • An HMAC-SHA256 signature over a timestamp and the payload hash, checked in constant time, with a 5-minute window.
  • Header checks before the body is read, so unsigned callers can't make it buffer large uploads; per-file and per-request size limits.
  • Every file bound to the signed payload by SHA-256 and size; undeclared files are refused.
  • Strict validation and length limits on every field.
  • A submission never overwrites an existing candidate's details (anyone can type someone else's email into a public form); it only fills gaps.

Details: Connect a form.

CSRF and origins#

Browsers attach the sign-in cookie automatically, so a state-changing request (anything but GET, HEAD, OPTIONS) is accepted only when it provably comes from ATS's own pages: it carries the app's custom X-ATS-Request header (which a cross-origin page can't send without a CORS preflight that is never granted), or Sec-Fetch-Site: same-origin, or an Origin equal to ATS's own origin or PUBLIC_URL. Cross-site requests are always refused.

Give ATS its own hostname. Because same-origin requests pass, any script running on the same origin, such as CMS embeds or analytics and tag-manager scripts on a public website, could act with an HR user's session. Serve ATS from a hostname that serves nothing else, like ats.example.com.

Browser hardening#

  • The web app is served with a strict Content Security Policy (same-origin scripts only), frame-ancestors 'none', X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer and, over HTTPS, HSTS.
  • Applicant uploads are treated as hostile: they are served with a locked-down Content Security Policy (sandboxed, except PDFs, which may only load themselves) and can only be framed by ATS's own resume preview.
  • The JSON API can't be framed or load anything.

Your part#

  • Keep INGEST_SECRET and PROXY_SECRET secret, and long (the generators in the guides use 64 hex characters).
  • Never publish the self-hosted port; only the proxy may reach it.
  • Exempt only /ats/ingest from sign-in.
  • Back up DATA_DIR (or D1 and R2) and protect the backups like the live data.