This document captures the security mechanisms currently in the codebase and the gaps you'd want to close before pointing this at real customer data. It's organized as what's done, what's still missing, and a deployment checklist at the end.
Audience: engineers evaluating production-readiness, auditors, and operators reviewing the multi-tenant boundary.
- No passwords, ever. Recruiter sign-in is a magic link sent to a
pre-provisioned email. The link is single-use, ≤15 min TTL, marked
consumed in
recruiter_magic_linkson use. In dev, the URL prints to the server log; in prod, an email transport delivers it. - Phoenix.Token, audience-scoped salts.
lib/interview/auth/tokens.exdefines four token classes with separate salts, so a token minted for one purpose can't be replayed for another:Token Salt TTL Use bootstrap interview-bootstrap-v15 min hand-off from customer backend → iframe upload_bearer interview-upload-v160 min, refreshable candidate's tus PATCHes recruiter_upload interview-rec-upload-v160 min recruiter-recorded prompt-asset uploads recruiter_session interview-rec-session-v124 h, refreshable recruiter dashboard - Bootstrap tokens are single-use AND short-TTL. Consume is atomic
via a DB row in
sessions.bootstrap_consumed_at; replay attempts return:already_consumed. - Tenant API keys are hashed at rest. Only
prefix(for lookup) +key_hash(SHA-256 of the secret) live in the DB; the cleartext is shown once at creation. Seelib/interview/auth/api_keys.ex. - No third-party-cookie dependency. Cross-site iframes can't rely on cookies (Safari ITP, Chrome partitioning). All flow continuity (LV reconnect, tus PATCH, postMessage) is driven by tokens, not cookies. See PLAN §4.2.
- No rate limiting on
POST /api/auth/magic-links. An attacker can enumerate registered emails (request → log scrape would be needed in prod, but timing/error responses still leak existence) or DoS the email queue. Add a per-IP and per-email throttle. - No CAPTCHA / bot protection on the magic-link request endpoint.
- No 2FA / MFA. The magic link IS the "what you have" factor, but there's no TOTP option.
recruiter_users.rolefield exists with a default of"owner"— but is not enforced anywhere. Every authenticated recruiter has full tenant access. There is no read-only / viewer role.- Magic-link consume route doesn't accept a
?return_to=param (good — no open-redirect vector). Don't add one without a strict allowlist.
- Shared DB +
tenant_idcolumn on every tenant-owned table (interview_templates,interview_template_versions,template_questionsvia version,sessions,question_responsesvia session,prompt_assets,recruiter_users,tenant_api_keys,audit_events,webhook_deliveries). - All read queries are tenant-scoped at the context layer.
Interview.Playback.list_sessions/2,get_session/2,get_response_for_playback/2,list_templates_with_sessions/1all taketenant_idas the first argument and emitwhere: x.tenant_id == ^tenant_id. This is the load-bearing invariant for the recruiter dashboard. InterviewWeb.Plugs.RecruiterAuthis the canonical authentication- tenant-resolution plug. It resolves a session cookie OR
Authorization: Bearer rk_…to a(recruiter, tenant)pair and assigns:current_recruiter/:tenant/:current_scope. Every recruiter-side LV / controller reads its tenant from this assign rather than a URL parameter.
- tenant-resolution plug. It resolves a session cookie OR
- Existing tests verify cross-tenant isolation. See
test/interview_web/live/recruiter_sessions_live_test.exs— the test "lists only this tenant's sessions" creates a second tenant and asserts none of its data appears in the first tenant's listing. - Storage keys are per-tenant-prefixed:
tenants/<tenant_id>/sessions/<session_id>/...(seelib/interview/storage/local.ex). An adapter bug that ignored a key prefix would still produce a path that includes the tenant id, so cross-tenant overwrites are extremely unlikely. InterviewWeb.Plugs.EmbedCSPlooks up the candidate session, walks to its tenant, and emits aContent-Security-Policy: frame-ancestorsheader fromtenant.frame_ancestors. The CSP comes from the stored tenant config, never from a JWT claim or query parameter — a malicious customer can't expand its own frame-ancestors by tampering with their bootstrap token.
- No row-level security at the DB layer. Tenant isolation is
enforced in application code only. If a future query forgets the
where tenant_id == ^tidclause, it leaks. Postgres RLS would belt-and-brace this; it's not currently configured. - No automated cross-tenant audit on every query. Adding a custom
Ecto.Repo callback to refuse queries on tenant tables that don't
include a
tenant_idfilter would catch the omission class above. Not implemented. recruiter_users.roledoesn't currently differentiate access inside a tenant. If you want viewer-only recruiters who can replay but not delete, that gate needs writing.
- Per-tenant
frame-ancestorsCSP on the embed page (lib/interview_web/plugs/embed_csp.ex):- Looked up from
tenants.frame_ancestorsbased on the URL session_id. - Wildcards disallowed by default (PLAN §4.4). Tenants must enumerate explicit origins. Wildcards require an admin override with a documented risk acknowledgement (any subdomain takeover by the tenant becomes a clickjacking vector).
- If no ancestors are configured for the tenant, default is
'self'(deny external embedding).
- Looked up from
X-Frame-Optionsis explicitly stripped on the embed pipeline — it would override CSP in older browsers if both were sent.Referrer-Policy: strict-origin-when-cross-origin— query-string tokens (the fallback path when postMessage can't deliver early) don't leak to other origins.Permissions-Policy: camera=(self), microphone=(self), autoplay=(self)— only the embed origin can grant camera/mic. Even if a parent page iframes us hostilely, it can't proxy our permissions to a third frame.- postMessage protocol is versioned + nonce-protected. Both ends
validate:
event.originagainst a per-tenant server-issued allowlist;event.sourcematches the expected window reference;- a session-scoped
channelIdnonce, established at theready/handshake; - the message schema (
v,type, required fields) — anything unknown is rejected. Origin alone is not sufficient (any same-origin frame on the host could otherwise spoof messages).
- Cookies are not part of auth continuity in the embed flow. This was deliberate to survive Chrome partitioning + Safari ITP.
- No Content-Security-Policy outside the embed page. The recruiter
dashboard runs on
put_secure_browser_headersdefaults (which addX-Frame-Options: SAMEORIGIN,X-Content-Type-Options: nosniff, etc.) but noContent-Security-Policyis set. Any XSS in the recruiter app would have free run. Adding a CSP with'self'for scripts/styles + nonce-based inline allowances is recommended. - No SRI (Subresource Integrity) on the embed SDK script tag in
docs/integration.md. A compromise of the CDN delivery path could ship malicious JS to candidates. SRI hashes per release would mitigate. - The "parent-trust" boundary is intentional. The plan does NOT protect the candidate from a malicious host tenant: the host can withhold the token, overlay the iframe, or read postMessages we send to it. CSP only prevents unrelated sites from embedding the session. PLAN §4.4 names this explicitly — flag it in customer agreements.
protect_from_forgeryon every:browser,:embed, and:recruiter_formpipeline. All HTML forms include a CSRF token (<input type="hidden" name="_csrf_token" ...>); LV channel join also validates it.put_secure_browser_headersplug — addsX-Content-Type-Options,X-Frame-Options,X-XSS-Protection,X-Download-Options,X-Permitted-Cross-Domain-Policies.- Session cookie: signed (not encrypted),
same_site: "Lax". - Migrations / direct DB endpoint commitment in PLAN §13: app
connects via the pooler endpoint, migrations + LISTEN/NOTIFY use the
direct endpoint,
prepare: :unnamedset for transaction-mode pgbouncer compatibility. Reduces the chance of a misconfigured long-lived connection that could blow up under load.
- Cookie not marked
secure: trueby default. In production the endpoint shouldforce_ssl: [hsts: true](PLAN §runtime.exs has the example commented out). Without that, a downgrade on first request could expose the session cookie. Action: enableforce_ssland HSTS inconfig/runtime.exsbefore prod deploy. - Session cookie is signed, not encrypted. That's fine for the
current contents (
recruiter_tokenis itself a signed token, no PII), but if you ever store anything sensitive in the session, enableencryption_saltinendpoint.ex. - No request-size limit beyond Plug.Parsers defaults. Bandit enforces some, but tus PATCHes are explicitly large; if you ever expose a non-tus upload, set an explicit cap.
- HMAC-SHA256 signature over the raw request body, sent as
X-Interview-Signature: sha256=<hex>. Customer side verifies with their storedwebhook_secret. - Per-tenant secret auto-bootstrapped on tenant creation
(
Tenant.generate_webhook_secret/0, 32 random bytes → base64url). Recruiters can rotate via the dashboard. - URL policy (
Interview.Webhooks.URLPolicy) refuses, in prod:http://(onlyhttps://allowed);- hostname suffixes
.localhost,.internal,.local,.lan,.home,.corp,.intranet; - explicit hostnames
localhost,ip6-localhost,ip6-loopback; - IPv4 literals in private/loopback/link-local/CGNAT ranges;
- IPv6 unique-local / link-local / loopback ranges.
- Dev overrides (
allow_http_urls: true,allow_private_destinations: true) are scoped toconfig/dev.exsandconfig/test.exsonly. - Delivery retries via Oban with exponential backoff;
webhook_deliveriestable records every attempt; pruner job sweeps successful deliveries older than 90 days (configurable).
- Webhook secrets are stored plaintext in the DB. They must be
retrievable to sign outbound bodies (one-way hash won't work). To
belt-and-brace this, encrypt the column at rest with a separate KMS
key —
cloak/vaultlibraries cover this. Not implemented. - No customer-side public-key verification option (RSA/Ed25519). All webhooks use shared-secret HMAC. For enterprise customers, offering signed webhooks with a public key they can pin is the next step.
- No webhook delivery TLS pinning. We trust the system trust store; a compromised CA could MitM webhook deliveries.
- Per-tenant prefix on storage keys. Adapter routing alone can't cross tenants without an explicit programming error visible in code review.
- tus capture-instance fencing. Every tus PATCH carries an
Upload-Offsetand is bound to a single(response_id, capture_instance_id). A second tab / BFCache restore trying to write with the wrong instance gets HTTP 410 Gone and is fenced. See PLAN §5.1. - Atomic durability invariant. The PATCH handler writes bytes to
the storage adapter, then opens a tiny DB transaction to commit
bytes_uploaded. The IDB queue on the client only deletes a chunk after both ACKs land. This prevents the "we accepted bytes but they're not durable" failure mode. - No long-running DB transactions wrap object-store I/O. This is a PLAN §12.5 commitment to avoid pgbouncer footguns.
- Right-to-delete plumbing.
Interview.Capture.soft_delete_session/2flipsdeleted_at; theWorkers.SessionDeletionOban worker scrubs storage artifacts (idempotent), hard-deletes the response rows, fires thesession.deletedwebhook, and writes an audit event. The session row itself is preserved by default (audit trail) and hard-deleted only when its parent template_version is removed.
- No encryption at rest in the Local adapter. Files are on the host filesystem unencrypted. The S3 adapter (when configured) should enable SSE-KMS — PLAN §8.3 names this as required but it's not wired in.
- No tus
Upload-Checksumextension. PLAN §5.1 mandated SHA-1 per-chunk integrity; the server doesn't enforce it yet and the client doesn't send it. A malicious or buggy uploader could write bytes that don't match what the candidate recorded. Pre-existing gap, flagged in the v1.0 known-issues list. - No virus / malware scan on uploaded attachments (PDFs, images). Recruiters upload these from their own machines, so the risk surface is limited — but if any of those files get re-exposed (sharing, links, AI parsing), a scanner like ClamAV is worth adding.
- Playback streams through Phoenix. The recruiter playback controller checks tenant ownership, then streams bytes from storage through the app. Fine for low scale; at higher tenancy counts you'd want short-TTL signed CDN URLs that bypass the app — not implemented.
audit_eventstable recordstenant_id,actor_kind,actor_id,action,subject_kind,subject_id,ip_address,user_agent, free-formmetadatamap, andoccurred_at.- Recorded actions include (non-exhaustive):
magic_link.request,magic_link.consume,template.publish,template.set_current_version,template.delete_version,session.delete_request,session.delete,api_key.create,api_key.delete,webhook_secret.rotate. - Retention sweeper (
Interview.Workers.AuditPrune) deletes rows older than 365 days by default; the limit is configurable per deployment. - The
tenant_idFK isON DELETE nilify_allso deleting a tenant preserves the audit history (withtenant_id = NULL).
- No tamper-evidence. Audit rows can be modified by anyone with DB write access. For compliance regimes that require it, you'd want hash-chained rows or a write-only append log.
- No structured shipping to a SIEM. Audit rows live in the DB; surfacing them to e.g. Datadog/Splunk for retention beyond 365 days isn't wired up.
- All queries go through Ecto with parameterized bindings — SQL injection is not reachable.
String.to_existing_atom/1is used on user-supplied keys (e.g. the section toggle uses"versions" | "retake_policy" | "questions"→ atom).String.to_atom/1is never called on user input.- File-upload MIME whitelist for recruiter attachments:
image/png,image/jpeg,image/webp,image/gif,application/pdfonly. 25 MB max per file. Unsupported types get a 422 witherror: unsupported_type.
- No XSS sanitization for
template_questions.prompt_text. It's declared as markdown; the recruiter authoring path stores it verbatim, and the candidate-side LV renders it without HTML escape via Phoenix's default safe HEEx escaping. As long as we never usePhoenix.HTML.raw/1on user content, this is fine — but the field invites future "render as HTML" feature requests. Flag any PR that introducesPhoenix.HTML.rawon a recruiter-authored field. - PDF/image attachments are not stripped of metadata. EXIF in photos and document metadata in PDFs can leak recruiter PII. Not scrubbed.
SECRET_KEY_BASEis required at boot in prod (config/runtime.exsraises if missing).DATABASE_URLandOPENAI_API_KEYare env-driven;OPENAI_API_KEYis optional — without it, transcript jobs permafail with a warning, everything else works.
config/dev.exshas a hard-codedsecret_key_baseandconfig/test.exshas another. They're well-known and only used in dev/test, but a developer who copy-pastes the file to a "staging" deploy would expose it. Treat any dev secret as compromised.- No
mix deps.auditin CI. Dependency vulnerabilities will not be surfaced without manual hex security advisory checks. Recommend addingmix hex.audit(Hex 2.0+) or third-partysobelowto theprecommitalias. .gitignorecovers.env/.envrc/.DS_Storebut the pre-existing devsecret_key_baseis still inconfig/dev.exs. Acceptable for dev convenience but worth flagging.
- Candidate recordings are PII. Per-tenant
retention_days(default 90) drivesInterview.Workers.RetentionSweeper, which enqueuesSessionDeletionfor any session whosecompleted_at + retention_daysis past. Storage + response rows are scrubbed. - Right-to-delete API:
DELETE /api/sessions/:idtriggers the same worker. prompt_assets(recruiter-recorded video prompts) are kept forever by design (PLAN §3.4 +lib/interview/templates/prompt_asset.exmodule doc). They're studio content, not candidate PII.
- PII in logs.
candidate_email,recruiter_email, and IP addresses appear in audit rows and routinely in[debug]log lines. No PII redaction filter is configured. For GDPR jurisdictions add aLogger.add_translatoror use a structured logger backend that redacts known fields. - No data-residency switching. Storage adapter is single-region. PLAN §12.8 names "EU customer signs" as the trigger for regional buckets / regional Phoenix — not implemented.
Treat this as the required hardening pass before pointing real customers at the app.
- Set a strong
SECRET_KEY_BASE(≥64 bytes) via env. Never reuse the dev value. - Enable
force_ssl: [hsts: true]inconfig/runtime.exs. Confirm HSTS includes subdomains if the embed origin needs it. - Set
cookie: secure: trueonce HTTPS is enforced. - Configure Plug.SSL with
rewrite_on: [:x_forwarded_proto]if behind a load balancer. - Switch the storage adapter from
Interview.Storage.Localto the S3/Tigris adapter; enable SSE-KMS encryption at rest. - Confirm
Interview.Webhooksruns withallow_http_urls: falseandallow_private_destinations: false(defaults inconfig/config.exs; onlyconfig/dev.exsandconfig/test.exsflip them on). - Configure DNS + TLS for the recorder origin (
interview.yourdomain.com) and the CDN delivering the embed SDK. - For every tenant, set
frame_ancestorsexplicitly. Refuse to deploy with[]or["*"]unless that tenant has signed the wildcard-risk acknowledgement. - Tighten the
:browserpipeline with an explicit Content-Security-Policy header (script-src 'self', etc.) — not justput_secure_browser_headers. - Add rate limiting to
POST /api/auth/magic-links,POST /api/sessions, and the bootstrap-consume path.PlugAttackorhammerare the usual choices. - Wire up
mix hex.audit(orsobelow) in CI; fail builds on advisories. - Configure a log shipper with a PII redaction filter for
email,ip_address,user_agent. - Enable Postgres SSL (
ssl: trueinconfig/runtime.exs— example is commented). - Roll over the dev
recruiter_usersand tenant API keys before seeding production tenants. - Decide retention for
audit_events. Default 365 d may not match your compliance regime. - Confirm DPA template + SOC 2 controls owner (PLAN §8.3 flags these as out-of-scope for the code, in-scope for legal).
When reviewing a PR for tenant-isolation safety, check that:
- Every Ecto query on a tenant-owned table includes
where: x.tenant_id == ^tenant_idor joins through a parent that does. - The tenant id comes from
socket.assigns.tenant.idorconn.assigns.tenant.id, never from a path/query/body param. Interview.Playback.*functions that taketenant_idare called withcurrent_recruiter.tenant_id(or equivalent).- Storage keys are constructed with the tenant id as the first prefix component.
- Webhook recipients are looked up from
tenant.webhook_url— not from request bodies or referrer headers. - Audit log entries carry the correct
tenant_id. Missing tenant_id = orphaned audit row.
The class of bug to look for is "this LV/controller is mounted under
:recruiter so I have a current_scope, but the query inside this
helper takes a bare id from the URL and doesn't re-check the tenant
boundary." That's IDOR.