Auth
The authentication model is intentionally minimal. There is one owner per deployment defined by environment variables, and any number of viewers who join a workspace by typing a 16-character access code. There is no public sign-up, no third-party OAuth for sign-in, no password reset email path, and no role hierarchy beyond owner / viewer. Every guard the application has comes from this two-principal model, and the rest of this document describes how it works end-to-end.
Owner
The owner is the email address listed in OWNER_EMAIL and the password listed in OWNER_PASSWORD. There is no concept of an owner table in the database; the credentials live entirely in environment variables. Rotating either value implicitly invalidates every existing owner session everywhere — the application embeds a fingerprint of OWNER_PASSWORD into the session payload and checks it on every request, so a stale cookie issued before the rotation will fail validation the next time it's used.
Signing in calls POST /api/auth/login with the email and password. The server does a constant-time scrypt comparison with a 250 ms artificial delay to make timing attacks impractical, then issues an AES-256-GCM encrypted session cookie. The cookie is HTTP-only, SameSite=Strict, Secure in production, and lasts seven days. The payload inside the encrypted blob includes the role, the email, the issue and expiry timestamps, and the password fingerprint.
Login is rate-limited at two layers. The first layer is a per-IP throttle that caps any single IP at eight login attempts per minute. The second layer is a per-(email, IP) exponential backoff that kicks in only on failed attempts: zero or one failure incurs no penalty, two failures locks the pair out for five seconds, three for fifteen, four for one minute, five for five minutes, and six or more for fifteen minutes. A successful login clears the backoff bucket. Both layers live in memory so a real attacker could scale across IPs to bypass them, but the combination is sufficient for the personal-app threat model the app is built for.
Viewer
A viewer joins a workspace with a 16-character access code that the owner generates from the workspace's Settings page. Each workspace has its own code, and each workspace tracks a monotonically increasing codeVersion integer. When the owner rotates the code, the codeVersion increments and every previously-issued viewer session for that workspace immediately stops working — the session payload carries the codeVersion at issue time, and the server compares it against the workspace's current value on every request.
There are two ways a viewer ends up with a valid join. The first is the access-code flow: they paste the code into /access along with their email, and on success the server writes a ViewerJoin row recording the email, the workspace, the codeVersion that was active, the IP address, and the user agent. The second is owner-initiated invite: from Settings → Viewer joins, the owner types the viewer's email and clicks Add. The server writes a ViewerJoin row at the workspace's current codeVersion without an IP / UA — these rows are marked "invited" in the UI to distinguish them from real sign-ins. Both paths end up with the same database shape and behave identically afterward.
Viewers can switch between any workspace they hold a valid ViewerJoin for, straight from the sidebar dropdown, without having to re-enter the access code. The application looks up every join that matches their email and the workspace's current codeVersion, and the sidebar shows all of them.
Returning viewer
When a viewer revisits /access and types their email, the lookup endpoint returns the list of workspaces they can still re-enter — that is, every workspace where they have a ViewerJoin at the workspace's current codeVersion and where the workspace is not soft-deleted. If the list is non-empty the UI offers a "Choose workspace" picker; clicking a workspace re-issues a session without prompting for the code. If the list is empty (unknown email, all joins revoked, or workspaces deleted) the UI falls through to the access-code prompt.
The returning viewer flow is what makes the day-to-day UX feel like a logged-in account even though there's no password. Once a viewer has joined once, they can come back to any of their workspaces just by typing their email — the same email always lands in the same workspaces — until the owner either removes their join or rotates the access code.
Removing a viewer
The owner can remove a viewer's joins for a workspace from Settings. The action deletes every ViewerJoin row for that email under that workspace. It does NOT bump codeVersion, so other viewers keep their sessions; only the targeted email is forced through a fresh access code on next login. If you want to invalidate every viewer's session at once, rotate the access code instead.
Sessions
The session cookie is opaque ciphertext. The payload inside it is HMAC-SHA256-signed first, then AES-256-GCM encrypted with TOKEN_ENC_KEY. Forging a session therefore requires both SESSION_SECRET (for the signature) and TOKEN_ENC_KEY (for the encryption); reading the cookie value off a hijacked logging server reveals nothing about the user. The cookie name is rs_session, HTTP-only, SameSite=Strict, Secure in production, and seven days long.
On every request, the application decrypts the cookie, verifies the HMAC, checks the expiry, and for viewers, looks up the workspace to confirm the codeVersion still matches. Owner sessions also verify the embedded password fingerprint against the current OWNER_PASSWORD. Failure at any step is treated as no session — middleware redirects to /sign-in.
The application also accepts the legacy unencrypted format for backward compatibility, so sessions issued before the encryption upgrade still work until they expire naturally. New sessions always use the encrypted format (v2: prefix).
Guards
There are four layers of authorisation enforcement.
Middleware runs on every request and short-circuits to a redirect if the session cookie is missing or shaped wrong. It's a cheap pre-filter — it doesn't decrypt the cookie or hit the database, just checks the shape so unauthenticated requests don't waste cycles on a real handler.
Server components call getSession() and decide what to render based on the role. Owner-only pages (/settings, /integrations, /notifications, /profile) redirect to the workspace home if the session is a viewer. Read-allowed pages (/, /content, /tasks, /work, etc.) render for both but hide owner-only controls.
Server actions call requireOwner() before mutating anything. There's a single exception: requireWorkspaceAccess(workspaceId) lets viewers through for explicitly read-only actions (fetching Linear issue detail, comments, sub-issues, labels, team metadata). Every mutating action in the codebase still calls requireOwner() — there's no path through which a viewer can write data, even a forged action call.
API routes use a layered guard: requireSameOrigin(req) to reject cross-origin POST attempts, then getSession() for authentication, then the route-specific role check. Image uploads (/api/uploads) additionally validate the file's magic bytes against the declared mime type to refuse a renamed .html claiming to be an image.
Stored credentials
Four third-party tokens are stored in Postgres, all encrypted with TOKEN_ENC_KEY:
| Credential | Storage |
|---|---|
| Linear personal API key (per workspace) | Workspace.linearApiKey, AES-256-GCM ciphertext |
| dev.to API key (per workspace) | Workspace.devtoApiKey, AES-256-GCM ciphertext |
| PostHog personal API key (per workspace) | Workspace.posthogApiKey, AES-256-GCM ciphertext |
| Google OAuth refresh token (per workspace) | Workspace.googleRefreshToken, AES-256-GCM ciphertext |
The workspace access code is stored in plain text — it's only useful in combination with the workspace, and codeVersion rotation invalidates it anyway, so encrypting it would add complexity without meaningful security benefit. The owner password lives in env as plain text and is never written to the database.
Rotating TOKEN_ENC_KEY makes every previously-encrypted value unreadable. Each workspace owner will have to re-connect their integrations after a rotation. There is no migration path — the rotation is intentionally destructive so a leaked key is forced to be cycled with consequences.
Destructive actions
Anything that destroys data demands the owner password again. Deleting a workspace from the Danger zone requires re-typing the owner password and the exact workspace name. This is a deliberate layer above the session check: even if a session cookie has been stolen and somehow the SameSite + encryption layers fail, the attacker still cannot delete the workspace without knowing the password. The same pattern is reused for any future destructive action.
CSRF
The cookie is SameSite=Strict, which blocks cross-site cookie attachment by default in every modern browser. As belt-and-braces protection, every mutating API route also calls requireSameOrigin(req), which rejects the request if Sec-Fetch-Site or the Origin header indicates a cross-site origin. Server actions go through Next's own action ID + Next-Action header gate, which provides equivalent CSRF protection for that surface.
What's not in scope
Two things deliberately are not in scope. First, there is no audit log of owner actions — the application records ActivityLog entries for content / task changes but does not capture every owner mutation. If you need a full audit trail, add a Prisma middleware that logs every write. Second, there is no two-factor authentication. The threat model assumes the owner controls their device and password; if you need 2FA, the cleanest path is to put a reverse proxy (Cloudflare Access, Tailscale Funnel, an auth proxy) in front of /sign-in and let it handle the second factor before traffic reaches the app.