Security
Last updated May 30, 2026 · Effective May 30, 2026
This page is the technical companion to the Privacy Policy. It describes how the Service is built, what we encrypt, and how to report a vulnerability. Where this page and `/privacy` cover the same ground, this one goes deeper; where they would conflict, the Privacy Policy wins.
1. Authentication
Authentication is passkey-only (WebAuthn). There are no passwords stored anywhere — the User table has no password column and never has. Passkey ceremonies require user verification (Face ID, Touch ID, device PIN, or your password manager's biometric prompt) and use discoverable credentials. Your biometric data never leaves your device; we receive only the credential ID, public key, sign count, and the transports your authenticator advertises.
Lost-passkey recovery is the only fallback. It is intentionally narrow: a 24-hour cooldown between request and claim (so an attacker who briefly accesses your email cannot immediately take over the account), a single-use token, a 15-minute restricted session that can only add a passkey and revoke the old ones, and an audit row recording the request time and a SHA-256 hash of the requesting IP. We never email both halves of the recovery flow at once.
2. Encryption
2.1 In transit
Connections to friendsnotfeeds.com and app.friendsnotfeeds.com use TLS, with HSTS enforced via response headers (see § 4 below). Plain HTTP requests are redirected to HTTPS.
2.2 At rest — infrastructure
Our managed Postgres provider (Neon) and object-storage provider (Cloudflare R2) apply at-rest disk encryption across the underlying infrastructure. This is the baseline for any modern managed cloud database; it protects against physical-disk theft and similar attacks.
2.3 At rest — application-layer
On top of the infrastructure baseline, we apply application-layer AES-256-GCM — an authenticated-encryption scheme, via the Web Crypto API, with a 256-bit key derived from APP_ENCRYPTION_KEY — to:
- your account email address;
- your web-push subscription material (the per-device endpoint URL plus the signing keys used to deliver pushes);
- your private “how we met” notes about a friend;
- your account's federation private key.
The envelope format is v1:<iv_b64>:<ct_b64> — a 12-byte random IV followed by ciphertext and a 16-byte authenticated-encryption tag, both base64. The version prefix lets us migrate the scheme later without rewriting historical rows.
Email lookups go through a deterministic blind index — an HMAC-SHA-256 over the normalized address, keyed with a separate APP_BLIND_INDEX_KEY, truncated to 16 bytes. The index lets us check “is there a user with this email?” without decrypting any rows, while a stolen database dump remains unreadable without the HMAC key.
Single-use tokens (account recovery, email verification) are stored only as argon2id hashes, never in plaintext. Verification uses constant-time comparison.
2.4 Photo handling
Uploaded images are sniffed by their magic bytes (we do not trust the client-supplied content type), parsed for EXIF if extractable, then re-encoded to WebP via sharp. The re-encode strips all EXIF — the bytes we serve to your friends carry no camera, lens, exposure, time, or GPS metadata. We persist the structured EXIF on the post record so you can choose to display it; per-post share toggles let you hide camera, lens, exposure, taken-at, and (off by default) GPS independently.
Photos are stored under UUID-keyed paths in object storage; there are no user-controlled filenames. URLs handed to your browser are presigned with a 1-hour time-to-live for app views. RSS / Atom delivery is opt-in and off by default; when enabled, it uses a private, revocable feed token, and image bytes are served through a re-signing proxy at /media/[token]/[postId] that issues 60-second URLs per request — even a leaked URL stops working within a minute.
3. Sessions
We set one cookie: a first-party, HTTP-only, SameSite=Lax session cookie signed and sealed by iron-session with a per-environmentSESSION_SECRET. We do not set advertising, analytics, or cross-site tracking cookies, and we do not use browser fingerprinting.
4. Headers, CSP, and CSRF
Every response goes through a middleware that sets:
- Content-Security-Policy. Strict and per-request. Scripts must be same-origin or carry a per-render nonce (third-party scripts are only permitted when they carry that nonce); no inline event handlers. The same policy applies to images, fonts, and frames.
- Strict-Transport-Security with a long max-age and
includeSubDomains. - X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin.
- Cross-Origin-Resource-Policy on app-subdomain responses, with the apple-touch-icon path deliberately routed around the middleware so iOS PWA install still works.
Every state-changing request validates that the Origin header matches the expected host; mismatches are rejected before any application code runs. Stripe webhooks are exempt from the origin check (they come from Stripe's servers) but are signature-verified with STRIPE_WEBHOOK_SECRET against the raw request body before any payload is parsed.
5. Validation and authorization at every boundary
The codebase is TypeScript, strict mode end-to-end. Every API route validates its input with zod before doing anything else; oversized payloads, prototype-polluted objects, and type-confused inputs are rejected with a 400 and never reach the database layer.
Authorization is enforced in three pieces, applied to every non-public route: requireUser (you must be signed in and not suspended), requireFriendship (you must be an accepted friend of the post's author — returning 404 rather than 403 so the existence of a post is itself never confirmed to non-friends), and requireOwner (delete and edit operations include the viewer's ID in the SQL WHERE clause, so a stale session token can never delete someone else's row even if app-layer checks are wrong).
6. Rate limits
Sensitive endpoints are throttled per user and / or per IP:
- Lost-passkey recovery: at most 1 request per user per 24 hours, 5 per IP per 24 hours.
- Sign-up (when public sign-up is enabled): 3 per IP per hour, plus an edge-level bot challenge on Cloudflare.
- Post creation: 30 per user per hour. Comment creation: 60 per user per hour.
- Account export: 1 per user per 24 hours. Password-equivalent token endpoints (email change, billing checkout): 5–10 per user per hour.
7. Trust and safety
Authenticated users can report any post, comment, or user from a menu on the post itself. Reports email [email protected] immediately and persist as an audit row; the email subject flags suspected child sexual abuse material as P0 so it surfaces ahead of routine reports. We aim to action notice-and-takedown reports within 72 hours; CSAM is removed on detection and reported to the U.S. National Center for Missing & Exploited Children (NCMEC) and law enforcement.
Authenticated users can also block any other user from that user's profile page. Blocking hides the blocked user's posts and comments from you, hides your posts and comments from them, prevents new friend requests in either direction, and atomically tears down any existing friendship and shared group-invite memberships.
Confirmed abusive accounts can be suspended administratively; suspended accounts are signed out on their next request and cannot use any authenticated route until the suspension is lifted.
8. Secrets management
All production secrets live in our hosting platform's secrets store and are injected as environment variables at runtime. Build-time placeholders in our Docker image are obvious-looking strings that never reach a real environment; the env-loader rejects them at startup.
The two keys that protect application-layer encryption — APP_ENCRYPTION_KEY and APP_BLIND_INDEX_KEY — are 32 random bytes each, generated per environment and stored separately. We do not commit them, log them, or ship them to third parties; they are mirrored only to a restricted password manager held by the operator. Loss of the encryption key would render the encrypted columns unrecoverable — this is a deliberate property of the design, not a bug.
9. Incident response and breach notification
If we confirm a security incident that has affected your personal data, we will notify you without undue delay and in any event within 72 hours of confirming the breach, by email and (where appropriate) in-app. We will notify supervisory authorities where required by law. We will tell you what we know, what we don't yet know, and what we are doing about it; we will not wait for a complete picture before warning affected users.
10. Reporting a vulnerability
If you believe you have found a security issue, please report it privately to [email protected] rather than disclosing publicly. We aim to acknowledge within 72 hours and to keep you updated as we triage.
We do not currently run a paid bug-bounty program. We will, however, publicly thank security researchers in our release notes (with your permission) and credit substantive findings.
Out of scope for security reports: denial-of-service, social engineering of our staff or our hosting providers, attacks against accounts you do not control, and theoretical vulnerabilities without a concrete proof-of-concept. In scope: anything that lets an attacker read or modify another user's data, bypass authentication, escape the friend-graph permission model, execute code in our context, or extract our encryption keys.
11. What we are not doing (yet)
To set expectations honestly:
- We are not SOC 2 / ISO 27001 audited. The Service is operated by a single person at small scale; the cost of formal certification would not match the user base today.
- We do not commission regular external penetration tests on a fixed cadence. We do run continuous static analysis (semgrep, gitleaks) and dependency auditing on every change.
- We do not have a paid bug-bounty program (see § 10). This may change as the user base grows.
- We do not perform proactive content scanning (e.g. PhotoDNA) at upload time. Reports (§ 7) and NCMEC referral are the active path; this will be revisited as the user base grows or if regulation requires it.
If any of the above changes, we will update this page and the “Last updated” date above.
12. Contact
Security questions or vulnerability reports: [email protected]. Other contact channels — privacy, abuse, copyright, billing — are listed at /contact.