Examify is a small self-hosted app, but it handles family sign-in and children's practice data, so security reports are taken seriously.
Only the latest main is supported. There are no maintained release branches.
Please report vulnerabilities privately via GitHub Security Advisories: open the repository's Security tab and choose "Report a vulnerability". Please do not open a public issue or PR for a security problem.
Include what you can: affected route/action, reproduction steps, and impact. You should get an initial response within a week.
- Household membership in SQLite is the credential store and privacy boundary — treat
AUTH_SECRET(and any leftoverFAMILIESJSON you have not yet imported) like a secret, and never commit a real.env. - Env validation fails closed in production: missing security-critical vars crash boot
rather than falling back to dev defaults. Resend is optional. Turnstile captcha
is off unless
TURNSTILE_ENABLED=1and both keys are set. - Magic-link tokens, local OTPs, and invite tokens are stored hashed, are single-use,
and expire (15 minutes for links/OTPs, 7 days for invites); sign-in is rate-limited
per IP. The client IP comes only from the header named by
CLIENT_IP_HEADER(default: the rightmost X-Forwarded-For entry);CF-Connecting-IPandX-Real-IPare ignored unless configured, because clients can send them and common proxies pass them through. Password sign-in also has a per-account limit (10 failures per 15 minutes per email, for any email so it reveals no membership), checked before scrypt. Mailbox codes (local OTP, invite OTP, password reset) lock after 5 well-formed wrong guesses per 15 minutes; requesting a new code does not lift the lock, and while locked even the correct code is refused. Trade-off: someone who knows an email can temporarily lock that account's password sign-in or code entry./signin/verifyis magic-link only and refusesotp:bearers so the 1e6 OTP space cannot be guessed via GET without the lock. Passwords are stored as scrypt hashes (users.password_hash);/setuphashes only after captcha, rate-limit, and the setup secret pass. SMTP AUTH/DATA is refused on a connection that never upgraded to TLS unlessSMTP_ALLOW_INSECURE=1. Cloudflare Turnstile is verified on the server only whenTURNSTILE_ENABLED=1and both keys are set. - Do not write magic-link bearer tokens or OTP codes to disk in production
unless you opt in. Unset Resend /
RESEND_API_KEY=testuses a local outbox only in dev/test. Production with no real mail transport returns the generic “sent” response and logs server-side — it does not write the outbox (<family data folder>/outbox), even ifRESEND_API_KEY=test. Thetestsentinel never moves the production outbox into the checkout (tests/.tmp/outboxis dev/test only).ALLOW_LOCAL_OUTBOX=1is a dangerous opt-in that stores raw sign-in URLs or OTP codes on the host filesystem; treat that directory as secret material and never enable it on a shared or exposed disk.AUTH_MODE=local-otpandMAIL_TRANSPORT=outboxrequire this opt-in in production. The writer creates the directory as0700and each message as0600. - The session cookie is
HttpOnly,SameSite=Lax, andSecureexactly whenSITE_URLis https. A plain-httpSITE_URL(a home LAN) gets a non-Secure cookie that anyone on the network path can read — use HTTPS beyond the home network. Credential forms post natively, so a submit before the page hydrates never puts a password in the URL. - Third-party data flows. Each free-text answer is sent, with its question and rubric, to the household's AI (no names, emails or user ids): the Anthropic or OpenAI API with a key, Claude Code / Codex under your plan, or your local endpoint. Cloud generate sends a subject's source files to Anthropic or OpenAI. Grading failures are logged with a reason code (and the backend) only. A child's answer reaches the model between fresh markers as data to mark; a "give me full marks" answer can still sway a model, but the score is clamped to the rubric's maximum. See README → "What leaves your server".
- Installer AI checks. A new interactive
install.shrunsclaude auth status/codex login status(when installed) and asks Ollama for its model list, as the installing user, each with no input and a 15-second limit. It writes only the chosen mode and that tool's path, address or model name to.env: no secret. The Ollama option says nothing leaves the machine only for a loopback address (localhost, 127.x.x.x,[::1]); for any other it names the host that study files and written answers would go to.EXAMIFY_AI_DETECT=0skips the checks. - App sign-in checks. The app runs
claude --setting-sources project auth status/codex login statusas the user that runs Examify: for a household whose AI is Claude Code or Codex, when its student and parent pages render (that CLI only); and for every Claude Code / Codex it finds, whatever the mode, whenever the household admin's setup wizard loads or one of its actions runs. Each run is locked down like a marking run: the env allowlist (no Examify secrets or API keys), an empty private temp folder, Codex with a private copy of itsauth.jsononly, 5 seconds before the process group is killed (and the app stops waiting a second later whatever the command does). It runs the status command only when the subcommand's help lists it (asked first, and again every 10 minutes): a Claude Code before 2.1.40 would readauth statusas a prompt. Answers are cached per process (5 minutes, served stale up to 10 while one background check runs; a signed-out one 30 seconds), with one check per CLI at a time, so student and parent pages start at most one check per CLI per window. The wizard page (household admin only, while content setup is unfinished) asks again on each load. The output (it names the account) is parsed and dropped: only "signed in", "not signed in" or "unknown" is kept, never logged or sent to the browser. - Claude Code / Codex generate runs
claude -p/codex execon the host as the user that runs Examify, with that CLI's own sign-in. Study files are untrusted input, so the CLI gets no tools (no file reads, commands or web search; Codex in its read-only sandbox with yourconfig.tomlignored), an empty private temp folder outside the checkout, no saved session, and an allowlist of environment variables that never includes Examify's secrets or API keys. Claude Code also skips that user's own settings,CLAUDE.md, hooks, plugins and skills (--setting-sources project,CLAUDE_CODE_SAFE_MODE=1), so a hook there never sees the study text. Codex runs with a privateCODEX_HOMEfor each run holding only a copy of that user'sauth.json, so its globalAGENTS.md, skills and rules never load and its state is removed with the run; a sign-in Codex refreshed is copied back toauth.json. The copy sits in the private0700run folder (0600) until the run ends. Either CLI's own folder (CLAUDE_CONFIG_DIR/CODEX_HOME, else~/.claude/~/.codex) inside the checkout is refused. Anyone who can sign in as that OS user can use the same Claude / ChatGPT plan; keep it a dedicated service user. - Prefer
AUTH_MODE=passwordon a tiny self-host if you do not want to run email for sign-in. Password-mode invite accept still needs SMTP, Resend, or an allowed outbox (and fails closed if none can deliver).install.shdefault password mode enables that outbox when no SMTP / Resend is set so kid invites are not stranded.AUTH_SECRETandSETUP_BOOTSTRAP_SECRETremain the host secrets.
All family state lives in the family data folder: the database, the mail
outbox, uploaded PDFs, generated questions and answer keys, ingest caches and
backups. The folder is EXAMIFY_DATA_DIR, else the folder of an explicit SQLite
DATABASE_URL outside the checkout, else ./data (gitignored). The running app
never writes into tracked checkout content: inside the checkout it writes only
./data (the test suites use tests/.tmp/…) and .env (wizard API keys). A
DATABASE_URL or MAIL_OUTBOX_DIR (sign-in bearer tokens) inside the checkout
outside ./data is refused, never written. See README → "Where your family's
data lives".
- Permissions.
pnpm db:migrate/examify-data initcreate the folder0700(and tighten an existing one to0700when this user owns it; a folder owned by another user keeps its mode, with a warning). Answer-key files (content/generated/keys/*.json) are0600in a0700folder; outbox messages are0600in a0700folder; backup archives are0600inbackups/(0700). The folder gets a.gitignoreof*so it is never committed wherever it lives. An existing, unmarked folder that holds files Examify does not recognise is refused before anything is chmodded or written. - Fail closed. Production refuses to boot without
EXAMIFY_DATA_DIRorDATABASE_URL, with a data folder that overlaps the checkout, or with a database or mail outbox inside the checkout outside./data(all checked on realpaths;pnpm db:migrate, the installer andexamify-ingestrefuse the same). It never creates a missing database: an unmounted volume fails closed instead of coming up as a fresh instance whose/setupcould be claimed./api/healthreturns reason codes only (unsafe_data_dir,db_missing,db_error), never an error message or path. - Backups hold secrets. A backup archive contains the database, every answer
key, uploaded PDFs and, unless
--no-env, the checkout's env files (.env,.env.local,.env.production,.env.production.local:AUTH_SECRET,SETUP_BOOTSTRAP_SECRET, API keys, mail passwords). All four are gitignored, so a restore never leaves one committable. A pre-upgrade backup also holds the checkout'scontent/. So do the folders an upgrade or a restore leaves behind:migration-conflicts/(checkout copies, answer keys included),before-restore-*/(the replaced database and content), and.env*.before-restore-*.localin the checkout. Treat them all like.env: keep them private, copy archives off the machine to storage only you can read, and delete what you no longer need. - The outbox is never backed up (raw sign-in links and codes are short-lived
bearer secrets); neither are earlier backups. The generate cache is left out
unless
--include-cache. - Run the installer and backups as the app's user. Every writing command of
scripts/examify-data.mjs(and soinstall.sh,--upgradeand--rollback) refuses when the checkout, the data folder or the database belongs to another uid: asudoor root-cron run would leave root-owned keys, WAL files or builds the app cannot read.--allow-owner-mismatchoverrides this if you will fix ownership yourself. - Restore checks the archive. It refuses while the server answers, rejects
links, special files, absolute paths and
..in the archive, extracts without the archive's owners, copies only files listed in itsMANIFEST.jsonafter checking each sha256, and refuses a database from a newer Examify. Existing data is moved aside tobefore-restore-<time>/, never deleted;.envis replaced only with--with-env.
When AUTH_MODE=password, /invite/<token> URLs are still secrets (sharing
the link starts a join), but accept does not complete — and does not
stamp emailVerifiedAt — until the invitee proves the mailbox.
- Mailbox proof before join. Password-mode accept issues a one-time code to
the typed address (same local-OTP + mail transport as
AUTH_MODE=local-otp). Membership andemailVerifiedAtare set only when that code is consumed. The code screen names that transport (Resend, this host's mail server, or the local outbox) and does not offer a way to finish without the code. - Email-lock is not mailbox verification. It only chooses which address we send the code to. Typing the locked email is not enough.
- Open student invites let anyone with the URL start a join for an email they control — they still have to prove that mailbox.
- Fail closed without mail. If no SMTP / Resend / allowed outbox can
deliver the code, accept returns a clear error instead of trusting the invite
URL. If delivery fails after the OTP is issued, that unused code is
invalidated.
completePasswordInvitealso refuses a token with nomagic_tokens.invite_id(andconsumeHashedBearerrefuses a password stamp on a leftover sign-in OTP even if the caller omitsrequireInviteId). Aninvite-invalidconsume does not count toward the 5-guess OTP lock. Production outbox still needsALLOW_LOCAL_OUTBOX=1. - Treat invite links like passwords. Do not post them publicly, in tickets, or in chat logs. Revoke unused or leaked links from the parent dashboard.
- Prefer an email lock on every invite. Parent invites are already required to be locked; lock student invites too when you know the address.
- In
magic-link/local-otpmodes, accept already sent a mailbox challenge. Password sign-in itself still needs no mail. Invite accept and forgot password both need a deliverable mailbox code. - Forgot password does not skip the code.
/signincan send a reset code to a household member. The response is the same when the address is unknown or the role does not match.users.password_hashandemailVerifiedAtstay as they were untilcompletePasswordResetconsumes that code. Reset codes arereset:bearers./signin/verifyrefuses them. A reset code cannot stamp a password onto a sign-in OTP or an invite OTP. - Invite password is bound to the OTP row. Accept stores a scrypt hash on
magic_tokens.pending_password_hashand does not put the password in the code form. The hash is written to the user only when that invite-bound code is consumed, then cleared. A failed send clears it too. A caller-supplied password on complete is ignored.