Memo is built for sharing pre-public content with a small number of high-trust individuals. The threat model assumes:
/<slug> without a UA that looks human.| Secret | Where | Rotation policy |
|---|---|---|
CLERK_SECRET_KEY | Vercel | Rotate via Clerk dashboard, then deploy. |
NEON_DATABASE_URL | Vercel | Rotate via Neon dashboard, deploy. |
R2_* | Vercel | Rotate via Cloudflare, deploy, then revoke old. |
UPSTASH_REDIS_REST_TOKEN, QSTASH_* | Vercel | Rotate via Upstash. |
RESEND_API_KEY | Vercel | Rotate via Resend. |
MEMO_PASSWORD_PEPPER | Vercel | Never rotate — link passwords are hashed with it. Changing it invalidates every link password. |
MEMO_JWT_KEY_FALLBACK | Vercel | Random base64. Only used until the first daily rotation succeeds. |
CRON_SECRET | Vercel | Rotate by setting new value first, then deploy. Brief window where cron 401s (acceptable). |
SENTRY_DSN_MEMO | Vercel | Rotate via Sentry. |
LOGROCKET_APP_ID, CLARITY_PROJECT_ID | Vercel | Public-ish; rotation = create new project. |
Link passwords are stored as argon2id hashes:
hash = argon2id( pepper + ":" + salt + ":" + plaintext )
salt is a fresh 16-byte hex string per links row.pepper is the env var MEMO_PASSWORD_PEPPER, never rotated.timeCost=3, memoryCost=64MB, parallelism=1.Salting + peppering means even if links.password_hash leaks, an attacker can't rainbow it without also having the pepper.
See Visitor Gates for the full lifecycle. Key safety properties:
/api/cron/rotate-jwt-key; previous key kept for 48 h grace.HttpOnly + Secure + SameSite=Lax, Max-Age=86400.slug — a JWT for slug=A won't validate against loadLink('B') because no production code path mixes them, but defense-in-depth is on the roadmap.memo_vp)#Distinct from the gate JWT. Memo issues a second cookie to support cross-session attribution ("is this the 3rd visit by this browser?") without depending on email equality.
| Property | Value |
|---|---|
| Name | memo_vp |
| Scope | Path=/ (global, not per-link unlike memo_v_<slug>) |
| Value | Raw UUID v4 (validated against the canonical regex on read) |
| Lifetime | 365 days, rolling (refreshed on every view render) |
| Flags | HttpOnly, SameSite=Lax, Secure in HTTPS |
| Stored as | memo.visits.visitor_persistent_id (uuid, nullable, indexed) |
Threat model:
memo.visits, not in the cookie.HttpOnly so JavaScript can't read or modify it (prevents document.cookie exfiltration via XSS in user-uploaded HTML).SameSite=Lax blocks the most common CSRF abuse vectors.memo_vp is minted, treated as a new browser). This is expected behaviour and is the privacy escape hatch for visitors.memo_vp value (e.g. a stolen cookie jar) would be attributed to that browser's identity. Same risk profile as session cookie theft — mitigated by HttpOnly + Secure + the fact that the persistent ID alone doesn't grant access to anything (gate JWT is still required for content).See Observability → Visitor identity model for the full two-cookie state-transition table and how return / share_detected signals exploit this for cohort detection.
Two distinct policies, applied by src/middleware.ts:
Owner pages (/, /sign-in, /u/<username>/*, /api/owner/*):
X-Frame-Options: DENY
The base CSP is the Astro default with checkOrigin: true enforced by the framework — POST requests must come from a trusted origin.
Visitor pages (/<slug>/* — the public share surface at the root):
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline'
https://cdn.lr-ingest.io
https://www.clarity.ms
https://*.clarity.ms
https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self' https://*.lr-ingest.io https://*.clarity.ms;
style-src-elem 'self' 'unsafe-inline' https://cdn.jsdelivr.net;
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
X-Frame-Options: SAMEORIGIN
'unsafe-inline' for script-src is necessary because the hosted HTML projects often embed inline <script> tags (it's the whole point — owners upload pre-built decks/demos). We accept the tradeoff because the rest of the policy is tight: scripts must come from same-origin or the whitelisted vendor CDNs (LogRocket, Clarity, and jsdelivr for the self-hosted rrweb loader), no iframes from other origins, no data: scripts, no form submissions to elsewhere.
scraperGate in src/middleware.ts runs only on visitor pages (/<slug>/* — anything not on /u/, /api/, /oauth/, /.well-known/, or another reserved root). It refuses any request with:
User-Agent/(curl|wget|httpie|python-requests|scrapy|bot|spider|crawler|headlesschrome|phantomjs|httrack|libwww-perl|java\/|ahrefsbot|semrushbot|mj12bot)/iRefusal returns 404 Not Found with X-Robots-Tag: noindex,nofollow. Returning a 404 (rather than 403) avoids leaking that the slug exists.
Owner pages (/u/<username>/*) deliberately do not run this gate — Clerk handles bot traffic on the auth surface, and we don't want to lock out CLI tooling that owners might use.
All limiters are sliding-window via @upstash/ratelimit:
| Endpoint | Identifier | Limit |
|---|---|---|
/api/v/<slug>/email | slug + IP | 10 / min |
/api/v/<slug>/otp | slug + IP | 10 / min |
/api/v/<slug>/password | slug + IP | 10 / min |
/api/v/<slug>/sign | slug + IP | 5 / min |
/api/beacon | slug + IP | 60 / min |
/api/recording/logrocket | visit + IP | 6 / min |
/api/recording/rrweb/events | visit + IP | 60 / min |
/api/s/<spaceSlug>/{email,password} | slug + IP | 10 / min |
/api/s/<spaceSlug>/otp | slug + IP | 5 / min |
/api/s/<spaceSlug>/sign | slug + IP | 10 / min |
/api/<slug>/qa (POST) | link + visit + IP | 10 / min |
Crons (/api/cron/*) are not rate-limited because they are authorized by either a Bearer secret (Vercel native cron) or an Upstash-Signature header (QStash). Unauthorized requests return 401 Unauthorized regardless of rate.
Security work that landed with the data-room surface (Spaces, Q&A, cloud OAuth). Each item is covered in depth on its feature page; summarized here for the threat model.
lib/crypto/secret-box.ts (also used by the webhook secret store). The *_encrypted columns now hold ciphertext (iv:tag:ct); the GCM tag makes tampering detectable. Key: PROVIDER_TOKEN_ENCRYPTION_KEY (falls back to WEBHOOK_ENCRYPTION_KEY). Tokens are never logged. See Integrations./api/oauth/<provider>/callback nonce is mandatory and single-use; the owner identity is read from the server-side Redis entry, never from the attacker-decodable state. PKCE S256 is added on the outbound flow. The provider is allowlisted (no SSRF), and the post-auth redirect is hardcoded first-party (no open redirect). See Integrations.PUT /api/owner/spaces/<id>/projects now requires the supplied set to equal existing membership (reorder-only); attaching is POST-only and validates projects.owner_id = caller. See Spaces → Security notes./api/owner/* route is authenticated (withOwner) and every [id] route re-confirms ownership before read/mutate — verified across the new spaces/teams/diligence/qa routes. The static nda-templates route was wrapped in withOwner to match the convention.signatures.space_id (not the links-FK link_id) to avoid a FK-violation crash. See Visitor Gates → NDA signing.Every state-changing endpoint writes a row to memo.audit_logs via src/lib/security/audit.ts. Current coverage:
| Action | Where it fires |
|---|---|
project.create | POST /api/owner/projects |
project.publish | POST /api/owner/projects/<id>/publish |
project.upload | POST /api/owner/projects/<id>/upload |
project.file.write | PUT /api/owner/projects/<id>/files/<path> |
project.file.delete | DELETE /api/owner/projects/<id>/files/<path> |
link.create | POST /api/owner/links |
link.update | PATCH /api/owner/links/<id> |
link.delete | DELETE /api/owner/links/<id> |
link.expire | expire-links cron (no actor) |
agreement.create | POST /api/owner/agreements |
nda.sign | POST /api/v/<slug>/sign (visitor email as actor) |
project.search | POST /api/owner/projects/<id>/search — find-in-files. Meta records the query, regex/case flags, and the resulting fileCount / matchCount / truncated so audit logs can spot abnormal scan patterns. |
project.export.staged | GET /api/owner/projects/<id>/export/staged.zip — owner downloaded the editor buffer as a ZIP. |
project.export.version | GET /api/owner/projects/<id>/export/version/<versionId>.zip — owner downloaded a published version as a ZIP. |
project.upload | POST /api/owner/projects/<id>/upload — multi-file or ZIP upload into the staging buffer. Meta: written, totalBytes, skipped[]. |
webhook.create / webhook.update / webhook.delete | Owner webhook CRUD (UI or MCP). |
visit_signals.derive | /api/cron/derive-cross-visit-signals (no actor — cron is the actor). Meta: windowHours, returnCount, shareCount so you can confirm the cron is running. |
The audit() helper swallows errors so a Postgres outage on the log table never breaks a user flow. Tradeoff: a brief outage may lose entries. The alternative — failing the parent request — is worse for this product.
| Threat | Mitigation |
|---|---|
| Stolen owner credentials | Clerk MFA + per-action audit log |
| Stolen visitor cookie | 24 h max age + 48 h key rotation grace; cookie is bound to slug + HttpOnly |
| Link password leak | argon2id + per-row salt + env pepper |
| Bulk download / scraping | scraperGate + Cache-Control: private, no-store + per-asset gate re-check |
| Visitor leaks to another party | Watermark per visitor (email + date); LogRocket replay; download disabled by default |
| Wrong-domain serving | hostGate returns 404 if Host is not in ALLOWED_HOSTS |
| Clickjacking | X-Frame-Options: SAMEORIGIN on visitor pages (/<slug>/*), DENY everywhere else |
| MITM | HSTS preload (max-age=63072000; includeSubDomains; preload) |
| Cron impersonation | Vercel CRON_SECRET bearer or Upstash signature |
| NDA repudiation | agreement_version_hash frozen at sign time + IP + UA + audit row |
| Path traversal in uploads | normalizeProjectPath regex per-segment + zip-entry validation |
Memo's machine surface (/api/mcp + the OAuth endpoints) inherits all of the above and layers on a few new concerns.
| Concern | Mitigation |
|---|---|
| Raw bearer token leaked from a dump | sha256-only storage, raw never persisted |
| Stolen OAuth access token | 1h hard cap; OAuth refresh requires the matching refresh token |
| Replayed OAuth refresh token | rotated_at makes each refresh row single-use; replay returns invalid_grant |
| Compromised OAuth client | One-click revoke at /u/<username>/settings/connections revokes the access token + every refresh token from the same client in a single transaction |
| Confused deputy / wrong-client redirect | Authorization codes are bound to (client_id, redirect_uri, code_challenge); mismatch on any of those fails the exchange |
plain PKCE downgrade | We advertise code_challenge_methods_supported: ["S256"] and reject any other method at issue time |
| Confidential-client secret exposure | We never issue or accept a client_secret. AS metadata advertises token_endpoint_auth_methods_supported: ["none"]. |
| PAT in a public repo | One-click revoke at /u/<username>/settings/tokens; last_used_at shows whether it was already used |
| Scope escalation | Tools check scopes individually; mint UI only requests what the AS metadata advertises |
| MCP rate abuse | Upstash sliding window: 60 calls/min per (bearerId, IP) |
| Phishing on the consent page | Consent UI renders the registered client name + URL + logo so the user sees who they're approving |
Every state-changing MCP tool writes an audit_logs row with meta.actor set to either mcp_pat:<token_id> or mcp_oauth:<access_token_id>. To find every change a specific OAuth connection made:
SELECT ts, action, target_type, target_id, meta
FROM memo.audit_logs
WHERE meta->>'actor' = 'mcp_oauth:<access_token_id>'
ORDER BY ts DESC;
OAuth-flow actions also write their own rows:
action | Where it fires |
|---|---|
oauth.client.register | RFC 7591 dynamic registration |
oauth.authorize | A user clicks Authorize on the consent screen |
oauth.token.issue | /oauth/token mints a fresh pair from a code |
oauth.token.refresh | /oauth/token rotates a refresh token |
oauth.token.revoke | /oauth/revoke is hit by the client |
oauth.connection.revoke | Owner clicks Revoke in /settings/connections |
pat.create / pat.revoke | Owner manages a PAT in /settings/tokens |
ai.generate | Owner uses Generate-with-Claude in the editor |
:root overrides — schema supports it; admin UI deferred.