Security model

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

Authentication#

Gooseplicant never handles passwords. Something in front of it signs people in, and Gooseplicant 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. Gooseplicant 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 Gooseplicant under Settings → Users & access; emails in BOOTSTRAP_ADMINS are always admins so you can get in the first time.

RoleCan
ViewerRead applications and download their files.
RecruiterRead; change stage, rating, job and location; write notes; manage jobs; reveal sensitive answers; email applicants; export CSV and zip; import past applications.
AdminEverything a recruiter can, plus users, settings (including locations and their aliases), 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, and Gosling uses the same API and the same rules.

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, the talent-pool matcher's text, CSV exports and the application.txt and applicants.csv in zip exports.

Audit log#

With the audit module on (the default), Gooseplicant records who viewed an application, revealed sensitive answers, downloaded a file, exported CSV or a zip, imported a past application, moved an application to another location, 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).

Reveals, downloads, exports, erasures and settings changes are written before the action completes: if the audit entry can't be stored, the request fails and nothing is revealed, streamed or exported. A zip export is logged once, with its size and the selection or filters; the file downloads that fill the zip are covered by that entry for an hour, for the same person and only for the applications in the export, instead of one entry per file. Each imported application is logged with the kind of file it came from.

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.

Imported applications keep their original date and the clock counts from it, not from the import, so an old application is due as if it had always been here. The importer skips anything already past the retention period unless you opt in. See Dates and retention.

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. Self-hosted, you can also serve the intake on its own port with nothing else mounted and keep the app off the internet: Public intake, private app.

Import and export#

  • Import needs the import capability (admins and recruiters) and access to the target location. Files are parsed in the browser and uploaded only after review, with the same file binding (SHA-256 and size per declared file) and size limits as the public intake. Imports never trigger candidate email or HR notifications, never add anyone to the talent pool, and an imported record without an email address is never merged with another person. See Import old applications.
  • Zip export needs the export capability (admins and recruiters). An explicit selection must be visible to you in full, otherwise the request is refused, so nobody gets a quietly partial archive and nothing leaks about records elsewhere. The zip is built in the browser; the server only hands out a manifest of non-sensitive data and file links. See Zip export.

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 Gooseplicant'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 Gooseplicant's own origin or PUBLIC_URL. Cross-site requests are always refused.

Give Gooseplicant 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 Gooseplicant 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 Gooseplicant'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, or publish only the intake-only port (INGEST_PORT).
  • Back up DATA_DIR (or D1 and R2) and protect the backups like the live data.