Different Hunger

Security

Staying signed in

Why an owner signs in once and stays in for 90 days, how the owner cookie works, and the rule every new gate must follow.

Staying signed in

An owner signs in once per domain and stays in for 90 days. Every visit in the last 30 days of that window renews it. Deleting the Supabase session does not log an owner out.

The two cookies

CookieSet byLifetimeJob
sb-…-auth-tokenSupabase~1 hour access tokenProves identity at sign-in
ohr_ownerPortal90 days, signedRemembers that a studio owner proved it

ohr_owner is expiry.base64url(email).HMAC-SHA256(owner:<email>|<expiry>), signed with PORTAL_SESSION_SECRET (falls back to INTERNAL_API_TOKEN). It is httpOnly, secure and sameSite=lax. It is minted only for emails in STUDIO_OWNER_EMAILS. Rotating the secret logs every owner out everywhere, instantly.

Where it is minted

  • Middleware (every request): lib/security/owner-cookie-edge.ts mints or renews it whenever a live owner Supabase session is present. It uses Web Crypto, because Edge has no node:crypto.
  • Sign-in completion: app/auth/callback and app/auth/confirm mint it through lib/security/owner-cookie.ts.

Both produce byte-identical tokens. Either one is verified by verifyOwnerToken() in lib/security/portal-session.ts.

Where it is honored

  • isRememberedOwner(), which requireBrandAccess() and requireOperator() use.
  • isBrandOwner(), which covers /blueprint, /studio/[slug], /offer preview and the contacts APIs.
  • /api/blueprint/unlock, the Edit button on a published blueprint.

The rule. A new owner gate calls isBrandOwner() or isRememberedOwner(). Never auth.getUser() alone. A raw Supabase check expires in an hour, and that one mistake is the entire "why do I keep logging in" bug.

Why it kept breaking (history)

  1. 2026-09-02. The cookie was built, but the mint lived in Edge middleware using node:crypto. It threw on every request, a catch swallowed the error, and the cookie was never set.
  2. 2026-09-09. The mint moved to the sign-in callbacks. An owner who was already signed in still never got the cookie.
  3. 2026-09-12. Blueprint, studio and unlock checked only Supabase, so even a valid cookie was ignored. Fixed by honoring the cookie in isBrandOwner and unlock, and by minting it in middleware with Web Crypto.

Proof (2026-09-12, prod 0hr.app): signed in through the magic link, then deleted every sb- cookie. After that, /api/blueprint/unlock?slug=different-hunger still returned ok: true.

Each domain signs in separately

Browsers scope cookies to the domain, so 0hr.app and differenthunger.app each need one sign-in.

Open: differenthunger.app sign-in. Supabase rewrites that domain's magic-link redirect_to to https://0hr.app, because the domain is missing from the Auth redirect allow list. The PKCE verifier lives on differenthunger.app, so the sign-in cannot complete. Fix: in Supabase → Authentication → URL Configuration → Redirect URLs, add https://differenthunger.app/** and https://www.differenthunger.app/**.