Circle V2 uses Supabase Auth under the hood but owns its own session cookies rather than letting @supabase/ssr manage them directly. The packages involved are:
| Package | Purpose |
|---|---|
@repo/auth |
Framework-agnostic auth client (SupabaseAuthClient) and getUserFromToken (token → User). |
@repo/auth-next |
Next.js-specific session helpers, request handlers for /api/auth/*, client hooks, and the middleware refresh primitive. |
@repo/storage/client |
Client-side upload entry point; the only browser code that needs to materialize an access token outside of cookies. |
V2 stores its session in three cookies. They are written by login/refresh paths and never read by the browser directly -- all reads go through @repo/auth-next server helpers.
| Cookie | Owner | Lifetime | Notes |
|---|---|---|---|
access-token |
V2 | Until refreshed/cleared | Supabase JWT. exp claim drives refresh decisions. |
refresh-token |
V2 | Until rotated/cleared | Used by middleware to rotate the pair. |
user-state |
V2 | Session, or 10 minutes for password-reset |
Sentinel that distinguishes "authenticated" from intermediate flows like password reset. |
sb-<ref>-auth-token(.N) |
Legacy v1 (browser) | Cleared on logout only | Residual cookies from prior v1 logins. V2 never reads them; logout deletes them so they cannot linger. |
Constants are defined in packages/auth-next/src/lib/cookies.ts.
Server components, server actions, and route handlers read the session through two helpers in packages/auth-next/src/lib/session.ts:
import { getLoggedInUser, requireUser } from "@repo/auth-next";
// Nullable read -- use when the caller can handle anonymous access.
const user = await getLoggedInUser();
// Throws via redirect("/login") when there is no valid session.
// Use this at the top of any (app) layout/page that requires auth.
const user = await requireUser();
Both helpers:
access-token from cookies.profile + user_entity rows, and return a User.sub if the profile/entity rows don't exist yet.Users without a v2 access-token cookie (including those with only residual browser sb-* cookies from a prior v1 login) are treated as unauthenticated and must log in again.
requireUser() additionally validates user-state. If user-state === "authenticated" but getLoggedInUser() returned null, the session is corrupt and the user is redirected through /api/auth/logout to fully clear cookies before bouncing to /login -- this avoids a redirect loop where a stale cookie repeatedly resurrects a dead session.
Authentication is enforced server-side in the (app) layout, not via Edge middleware:
// apps/web/app/(app)/layout.tsx
const user = await requireUser();
See Web App Architecture › Authentication Boundary.
The tRPC context calls getLoggedInUser (not requireUser) so unauthenticated procedures remain reachable:
// apps/web/lib/trpc.context.ts
export async function createTRPCContext() {
const user = await getLoggedInUser();
return { user };
}
Procedures that need auth assert ctx.user in a middleware.
Supabase access tokens expire (default: 1 hour). V2 refreshes them proactively in Next.js middleware so every authenticated request sees a fresh JWT. Refresh orchestration lives on BaseAuthClient.refreshTokensIfNeeded; refreshSessionFromCookies in @repo/auth-next only reads/writes v2 cookies.
apps/web/middleware.ts runs on every page navigation, RSC request, and tRPC call. It calls refreshSessionFromCookies, which delegates to authClient.refreshTokensIfNeeded():
exp from the current access token (or refresh when access is missing but refresh is present).exp - now > 120s, do nothing.BaseAuthClient.refreshSession(refreshToken) via SupabaseAuthClient, write the rotated access-token / refresh-token back to both request.cookies (so the downstream handler sees them in the same request) and response.cookies (so the browser persists them).All session cookies use shared httpOnly / secure / sameSite: lax options from session.cookie-store.ts.
The middleware runs on the nodejs runtime so it can share @repo/logger with the rest of the app.
matcher: [
"/((?!_next/|v2/_next/|favicon.ico|api/).*)", // pages + RSC
"/api/trpc/:path*", // tRPC is the only authed /api surface
]
Other /api/* routes (auth handlers, webhooks) are intentionally excluded -- they manage their own cookies or don't use sessions.
refreshSessionFromCookies returns one of:
| Status | When | Effect |
|---|---|---|
noop |
Token is fresh or absent. | No cookie writes. |
refreshed |
Refresh succeeded. | Both cookies rotated. |
transient-error |
Supabase 5xx / refresh_token_already_used (lost a refresh race). |
Keep existing cookies. The current access token is likely still valid; the next request will retry. |
cleared |
Permanent failure (refresh token revoked, missing, malformed). | Delete both cookies; user is redirected to login on the next requireUser(). |
Concurrent requests inside the refresh window can each call refreshSession with the same refresh token; Supabase's ~10s reuse window catches most races, and refresh_token_already_used is treated as transient so the loser doesn't get logged out.
Direct browser → Supabase Storage uploads (audio note recordings, patient chart uploads) need an explicit access token in an Authorization header (not just a cookie). tRPC requests do pass through middleware (matcher includes /api/trpc/*), so tokens are refreshed on the same 120s buffer before upload credentials are fetched.
The single entry point is useUploadCredentials → storage.uploadCredentials tRPC query → uploadFile from @repo/storage/client:
import { uploadFile } from "@repo/storage/client";
import { useUploadCredentials } from "~/lib/useUploadCredentials";
const { getUploadCredentials } = useUploadCredentials();
const config = await getUploadCredentials();
await uploadFile(config, { bucket, key, body, contentType });
The tRPC refresh link handles any remaining 401s by calling /api/auth/refresh before retrying.
Do not read
access-tokenfromdocument.cookieor callsupabase.auth.getSession()ad-hoc — that bypasses the unified refresh path.
/api/auth/* endpoints are standalone HTTP handlers that wrap request handlers from @repo/auth-next/api:
| Route | Handler | Notes |
|---|---|---|
POST /api/auth/login |
login.request-handler |
Email + password → sets the three V2 cookies. |
POST /api/auth/signup |
signup.request-handler |
|
GET /api/auth/oauth-callback |
oauth-callback.request-handler |
PKCE return URL. |
GET /api/auth/google |
google-login.request-handler |
OAuth kickoff. |
POST /api/auth/forgot-password |
forgot-password.request-handler |
Sends OTP email. |
POST /api/auth/verify-otp |
verify-otp.request-handler |
Sets user-state: "password-reset" (10-min TTL). |
POST /api/auth/reset-password |
reset-password.request-handler |
Requires user-state: "password-reset". |
POST /api/auth/change-password |
change-password.request-handler |
Requires user-state: "authenticated". |
POST /api/auth/update-password |
update-password.request-handler |
|
POST /api/auth/accept-invite |
accept-invite.request-handler |
|
* /api/auth/logout |
-- | Deletes access-token, refresh-token, user-state, and any legacy sb-*-auth-token cookies. |
Form components call these flows through React hooks in @repo/auth-next/hooks -- e.g. useLoginWithEmailAndPassword, useSignupWithEmailAndPassword, useChangePassword. The hooks wrap a fetch to the corresponding /api/auth/* route and surface a Safe<T> result.
While v1 iframes and a few integration endpoints still call the legacy app:
sb-* cookies for v2 session auth — users must have v2 access-token / refresh-token from a normal login.sb-* browser cookies on logout.sb-* cookies only for outbound server-to-server calls to the legacy app (integration sync routes via LegacyApiClient), built from the current v2 token pair — not forwarded from the browser.getLegacyIframeSrc), not browser sb-* cookies.Users who still have only legacy browser cookies must re-login once to receive a proper v2 session.