Circle V2 API Docs
    Preparing search index...

    Authentication and Sessions

    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:

    1. Read access-token from cookies.
    2. Decode the JWT, look up the profile + user_entity rows, and return a User.
    3. Auto-complete any pending invite for the JWT's 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():

    1. Decode exp from the current access token (or refresh when access is missing but refresh is present).
    2. If exp - now > 120s, do nothing.
    3. Otherwise call 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 useUploadCredentialsstorage.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-token from document.cookie or call supabase.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:

    • Never read browser sb-* cookies for v2 session auth — users must have v2 access-token / refresh-token from a normal login.
    • Clear residual sb-* browser cookies on logout.
    • Synthesize 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.
    • Authenticate iframes via encrypted tokens from v2 cookies (getLegacyIframeSrc), not browser sb-* cookies.

    Users who still have only legacy browser cookies must re-login once to receive a proper v2 session.


    Back to Table of Contents