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
| Cookie | Set by | Lifetime | Job |
|---|---|---|---|
sb-…-auth-token | Supabase | ~1 hour access token | Proves identity at sign-in |
ohr_owner | Portal | 90 days, signed | Remembers 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.tsmints or renews it whenever a live owner Supabase session is present. It uses Web Crypto, because Edge has nonode:crypto. - Sign-in completion:
app/auth/callbackandapp/auth/confirmmint it throughlib/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(), whichrequireBrandAccess()andrequireOperator()use.isBrandOwner(), which covers/blueprint,/studio/[slug],/offerpreview and the contacts APIs./api/blueprint/unlock, the Edit button on a published blueprint.
The rule. A new owner gate calls
isBrandOwner()orisRememberedOwner(). Neverauth.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)
- 2026-09-02. The cookie was built, but the mint lived in Edge middleware using
node:crypto. It threw on every request, acatchswallowed the error, and the cookie was never set. - 2026-09-09. The mint moved to the sign-in callbacks. An owner who was already signed in still never got the cookie.
- 2026-09-12. Blueprint, studio and unlock checked only Supabase, so even a valid cookie was ignored. Fixed by honoring the cookie in
isBrandOwnerand 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_totohttps://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, addhttps://differenthunger.app/**andhttps://www.differenthunger.app/**.