UNDERGROVE
AboutLog inSTART FREE

Information Security Program

Undergrove · Internal policy, published for transparency Owner: Reid Brenza · Last reviewed: 12 August 2026 · Review annually

Required in writing by the amended COPPA Rule. Written for a one-person business, honestly. An enterprise-style document full of roles nobody occupies would be worse than useless — it would be a document claiming controls that don't exist.


Scope and accountability

Reid Brenza is responsible for information security at Undergrove. Sole owner, sole operator, sole holder of production credentials. Where this document says "we," it means one person.

That's a real limitation and worth stating rather than papering over: there is no separation of duties and no second pair of eyes. The controls below are chosen to compensate — automated where possible, so they don't depend on one person remembering.

What we protect

Ranked by what would hurt a user most if exposed:

  1. Writing samples and drafts — personal essays, often about difficult experiences. The most sensitive thing we hold.
  2. Sensitive profile attributes (18+ only) — background, health, household.
  3. Account credentials and contact details.
  4. Purchase records.

We hold no Social Security numbers, bank account numbers, or card numbers. There is no field for any of them. That is the single largest reduction in breach impact available to us, and it was a design decision.

Controls

Access

  • Production credentials are held by the owner alone, in a password manager with a unique passphrase and two-factor authentication.
  • Two-factor authentication on: hosting, database, Stripe, Anthropic, domain registrar, email provider, GitHub.
  • No shared accounts. No credentials in the repository or in git history.
  • Admin functions require an authenticated admin session; the admin flag is set directly in the database, never through the app.

Data

  • Passwords hashed with bcrypt. Plaintext passwords are never stored, logged, or emailed.
  • Writing samples and drafts are encrypted at rest. These are personal essays, often about hardship, illness, family, or money — the most sensitive data we hold. A stolen database dump would show ciphertext, not the essay. The encryption key is injected at runtime from the platform's secret store, never stored in a file beside the database.
  • TLS on all traffic. HTTP redirects to HTTPS.
  • Secrets in environment variables, never in code. Production and development secrets are different values.
  • Sensitive attributes stripped server-side for under-18 users, so a crafted request can't insert them.
  • PII is not written to application logs.

Application

  • Rate limiting on signup, login, and search. No account lockout: repeated failures throttle with a growing delay rather than locking anyone out, since locking an account is itself a denial-of-service tool against its real owner. Signup and login closed 2026-08-12.
  • Parameterised queries throughout (SQLAlchemy) — no string-built SQL.
  • Dependencies reviewed for advisories before each deploy. Two known-vulnerable packages were already replaced (python-jose → PyJWT, passlib → bcrypt).
  • Automated tests must pass before deploy.
  • A build-time check fails on unknown CSS tokens, after three separate incidents of silently unstyled UI.

Operations

  • Automated daily database backups: NOT CURRENTLY RUNNING for the production deployment. Status corrected 2026-08-19 — this section previously said "confirmed," which was true of a different setup and is no longer true of the one now live. The 2026-08-15 confirmation below verified a launchd-scheduled backup running on Reid's own laptop; the production system is moving to Render, where backups run as a scheduled cron job (scripts/cron_daily.sh) with no persistent local disk to write to. That job's backup step (scripts/backup_encrypted.sh) deliberately refuses to run on Render until an off-host (S3-compatible) destination is configured — writing a backup to a disk that vanishes on the next restart would be worse than no backup, since it would look like protection. No such destination exists yet. Until it's provisioned, the production database has no working automated backup, full stop. See render.yaml's own comments for exactly what provisioning it involves.

    The 2026-08-15 confirmation, kept for the record: cron didn't fire reliably on a laptop that sleeps overnight; a subsequent switch to launchd surfaced a second blocker (the project's old path lacked Full Disk Access); both were resolved at the time. Confirmed by decrypting undergrove-20260815T060003Z.age on a machine holding the private key: 337 scholarships, 5 users, search_runs present. That machine and that backup path are not what the production deployment now runs on.
  • Restore is verified by decrypting a scheduled backup on a separate machine that holds the private key — that's the step that makes it a backup rather than just a file.
  • Restore tested quarterly. An untested backup is not a backup. First test: 2026-08-12, passed.
  • Error monitoring with alerting.
  • Staging environment separate from production. Production data is not copied to staging.

Third parties

Anthropic (AI), Stripe (payments), hosting/database. Each is reviewed for a published security posture before use. Stripe is PCI-compliant and we never touch card data.

Email: built, provider-agnostic, not yet sending. All three templates (parental consent notice, breach notification, reconciliation alert) are wired through one sending layer (app/services/email_service.py) behind an EMAIL_PROVIDER switch — Postmark by default, SMTP (e.g. Network Solutions) as a fallback if Postmark's DKIM can't be issued as a plain TXT record for this domain; every caller, template, and the bounce/complaint handling below is identical either way. Consent is a notice, not a verification claim — see the module's own docstring for why that distinction is legally load-bearing, and REID_MANUAL_STEPS.md for the SPF/ DKIM/DMARC records this needs on mail.undergrovescholar.com, not the root domain.

Status: blocked on one operational gap, not a code gap. email_service.MAILING_ADDRESS is unset — CAN-SPAM requires a physical postal address in every commercial email's footer, checked before any provider call, so every send currently refuses outright rather than going out without one. Every refusal is recorded and reported honestly (no page claims a consent email was sent unless it actually was); nothing here is silently broken. Needs the LLC's registered address from Reid.

Risk assessment

Reviewed annually, and whenever we add a data type or a processor.

Risk Likelihood Impact Mitigation
Credential compromise (owner) Low Severe 2FA everywhere, password manager, unique passphrases
Database exposure Low Severe No public DB access, credentials rotated on any suspicion
Writing-sample exposure Low High Text-only storage, no file store, hard-deleted on erasure
Third-party breach (Stripe/Anthropic/host) Low Medium No card data held; minimum data sent to Anthropic
Under-13 data collected in error Low High (regulatory) Server-side age gate, nothing persisted on block
Corpus loss Medium Severe (business) No working automated backup for the production deployment as of 2026-08-19 — see Operations above. Daily backups previously confirmed on a since-retired setup; quarterly restore test previously passed against that setup
Single-operator unavailability Medium Medium Documented procedures; see the response plan

Incident response

See the Data Breach Response Plan.

Training

There is one person, and he wrote this. Reviewed annually alongside the risk assessment. If Undergrove ever has employees or contractors with data access, this section gets rewritten before they're granted it.

Honest gaps

Stated deliberately, because a security program that claims to be complete is the least trustworthy kind:

  • No working automated backup for the production deployment right now — see Operations above for the exact gap and what closes it. Not stated softly: the mitigation this depends on does not currently exist.
  • Profile fields and email addresses are not encrypted at rest. Ranking and eligibility query profile fields (state, major, GPA, enrollment) constantly — an encrypted column can't be filtered in SQL, so this would mean loading every row into Python to answer any question. Email addresses are needed to send mail and to look up accounts. Both are protected by the same access controls as the rest of the database, not by a separate key.
  • No independent security review. Worth commissioning once there's revenue.
  • No formal penetration testing.
  • No separation of duties, and it can't be fixed at this size.
  • No SOC 2 or equivalent. Not expected at this stage; will be asked for if we ever sell to school districts.
TermsPrivacyProtecting Younger Usersundergrove@undergrovescholar.com

UNDERGROVE is an independent search and drafting tool. We do not own, create, fund, administer, judge, or award any scholarship, and we are not affiliated with or endorsed by any provider listed. We never submit applications on your behalf, and we cannot guarantee any award, response, or outcome.