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.devand 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.
The one-server install#
The one-server setup (./goose setup) builds the self-hosted model above out of Caddy and Authelia, with these choices made for you:
- Local accounts, no outside directory. Authelia signs people in against a users file on your server. Passwords are stored as argon2id hashes (64 MiB memory, 3 passes, parallelism 4), never in plain text, and must be at least 12 characters. goose hashes them locally and never puts a password on a command line.
- Two-step sign-in by default: an authenticator app (TOTP) or a passkey. Registering one is confirmed with a one-time code (by email, or
./goose codewithout SMTP). After 5 failed attempts within 2 minutes an account is locked for 10 minutes. Sessions end after 30 minutes of inactivity and 8 hours at most ("remember me": one month). - Deny by default. Authelia allows only the app hostnames, and only for signed-in people. What each person may do inside Gooseplicant is still decided under Users & access.
- Identity headers can't be forged. Before asking Authelia, Caddy deletes every identity header a browser could send (
Remote-*,X-Forwarded-Email,X-Forwarded-User,X-Forwarded-Preferred-Username,X-Forwarded-GroupsandX-ATS-Proxy-Secret). Only Authelia's answer setsRemote-Email, and Caddy adds thePROXY_SECRETthat Gooseplicant requires with it../goose doctortests this with forged headers. - Nothing but Caddy is published, and only on one internal address. Gooseplicant (ports 3000 and 3001), Authelia and cloudflared listen on the internal Docker network only; Caddy listens on the LAN or Tailscale IP you chose, never on all interfaces.
./goose doctorchecks both. - Private A records. The internal hostnames point at a private address and are DNS only: a public record reveals the name, not the server, and nobody outside your network or tailnet can connect. Certificates come from Let's Encrypt through Cloudflare's DNS, so no port is opened for them. You can keep the names in local DNS instead.
- Only the intake is public. The Cloudflare Tunnel forwards only
^/ats/(ingest|api/health)$, and only to the intake-only port; the main port refuses applications (MAIN_INGEST=false). cloudflared dials out, so no inbound port is opened. - Secrets stay on the server.
deploy/.env,deploy/secrets/, the generated config and the backups are readable only by the owner (mode 600/700). The Cloudflare API token is kept so Caddy can renew certificates: give it only the zones it needs, and remove the tunnel permission once setup is done.
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.
| Role | Can |
|---|---|
| Viewer | Read applications and download their files. |
| Recruiter | Read; change stage, rating, job, location and position; write notes; manage jobs; reveal sensitive answers; email applicants; export CSV and zip; import past applications. |
| Admin | Everything a recruiter can, plus users, settings (including locations, their aliases, their order and the order of their positions), the audit log and deleting candidates, one at a time or many at once. |
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. The built-in Unsorted location (imports filed to sort later) is visible only to people who see every site, admins and users with all sites; it can't be assigned to anyone, and for everyone else it doesn't exist, in lists, counts, imports and moves alike. Filing many applicants at once is all-or-nothing on visibility: if any of them, or the target location, is outside your sites, nothing changes. 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 defaultconvicted*andwork_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.txtandapplicants.csvin 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 or changed its position (one at a time, in bulk, or by dragging in Gosling), changed a stage, sent an email, deleted a candidate, or changed settings or users (reordering locations and positions included, recorded as settings changes to sites.order and sites.positionOrder), along with when and from which IP. Moves and position changes also appear in the applicant's activity, with the old and new value; filing someone where they already are writes nothing. 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.
Many at once (admins, in the inbox's bulk bar or Gosling's selection bar): every person in the selection is erased exactly the same way, one after another, with one audit entry per person, never a single entry for the batch. The confirmation names people who also have applications outside the selection, since erasure removes all of them, and from 10 people up asks for the number (or delete) to be typed. Only people within the admin's sites can be erased; anyone else counts as not found. If an erasure fails partway, what is already erased stays erased and audited, and nothing else in that request is touched. See Delete many applicants at once.
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-referrerand, 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_SECRETandPROXY_SECRETsecret, and long (the generators in the guides use 64 hex characters). - Never publish the self-hosted port; only the proxy may reach it. (The one-server setup does this for you, and
./goose doctorchecks it.) - Exempt only
/ats/ingestfrom 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.