Skip to main content
The Tuturuuu platform uses Supabase Auth for user authentication with support for email/password, OAuth providers, multi-factor authentication (MFA), and cross-app token authentication.

Authentication Flow

┌──────────────┐      ┌──────────────┐      ┌──────────────┐
│   Client     │──1──>│  Supabase    │──2──>│  Database    │
│ (Browser/App)│      │  Auth        │      │  (RLS)       │
└──────────────┘      └──────────────┘      └──────────────┘
       │                      │                      │
       │<─────────3───────────│                      │
       │  Session Token       │                      │
       │                      │                      │
       │──────4──────────────────────────────────────>│
       │  Authenticated Request (w/ session)          │
       │<─────────────────────────────────────────────│
       │  Data (filtered by RLS)                      │
  1. User signs in via Supabase Auth
  2. Supabase validates credentials against database
  3. Client receives session token
  4. Subsequent requests include session token for RLS

Cross-App Supabase Cookies

Production Tuturuuu browser sessions use one canonical Supabase auth cookie for the configured Supabase project and share it across *.tuturuuu.com by setting Domain=.tuturuuu.com. Portless local development does the same across *.tuturuuu.localhost with Domain=.tuturuuu.localhost. Keep the cookie host-only for plain localhost, preview deployments, and unrelated domains. Registered satellite apps still keep Tuturuuu app-session JWTs as a fallback and API isolation mechanism, so do not remove app-session refresh or handoff paths when updating shared Supabase cookie behavior.

Web Multi-Account Sessions

apps/web stores multi-account sessions in a server-owned vault instead of browser localStorage. The browser keeps only an HttpOnly device cookie and loads account summaries from /api/v1/auth/accounts; Supabase access and refresh tokens remain encrypted in private.web_account_sessions. Use WEB_MULTI_ACCOUNT_SESSION_SECRET for vault encryption when available. If it is not configured, the server falls back to SUPABASE_SECRET_KEY, SUPABASE_SERVICE_ROLE_KEY, then SUPABASE_SERVICE_KEY. Never expose those values to client components or upload legacy browser-stored sessions into the vault; users should re-add accounts after storage migrations.

Sign Up

Basic Email/Password Sign Up

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";
import { redirect } from "next/navigation";

export async function signUp(formData: FormData) {
  const supabase = createClient();

  const email = formData.get("email") as string;
  const password = formData.get("password") as string;
  const displayName = formData.get("displayName") as string;

  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: {
        display_name: displayName,
      },
      emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
    },
  });

  if (error) {
    return { error: error.message };
  }

  redirect("/verify-email");
}

Client Component

"use client";

import { signUp } from "./actions";
import { useState } from "react";

export function SignUpForm() {
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(formData: FormData) {
    const result = await signUp(formData);
    if (result?.error) {
      setError(result.error);
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="displayName" placeholder="Display Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      {error && <p className="text-dynamic-red">{error}</p>}
      <button type="submit">Sign Up</button>
    </form>
  );
}

Sign In

Email/Password Sign In

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";
import { redirect } from "next/navigation";

export async function signIn(formData: FormData) {
  const supabase = createClient();

  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) {
    return { error: error.message };
  }

  redirect("/dashboard");
}

OAuth Sign In

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function signInWithOAuth(provider: "google" | "github") {
  const supabase = createClient();

  const { data, error } = await supabase.auth.signInWithOAuth({
    provider,
    options: {
      redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback`,
    },
  });

  if (error) {
    return { error: error.message };
  }

  return { url: data.url };
}
Client Component:
"use client";

import { signInWithOAuth } from "./actions";

export function OAuthButtons() {
  async function handleOAuthSignIn(provider: "google" | "github") {
    const result = await signInWithOAuth(provider);
    if (result.url) {
      window.location.href = result.url;
    }
  }

  return (
    <div>
      <button onClick={() => handleOAuthSignIn("google")}>
        Sign in with Google
      </button>
      <button onClick={() => handleOAuthSignIn("github")}>
        Sign in with GitHub
      </button>
    </div>
  );
}

OAuth Callback State

For provider-specific OAuth callbacks that need to carry ephemeral verifier or CSRF state across a browser redirect, prefer a short-lived HttpOnly cookie scoped to the callback route. When the callback binds external credentials or mutates a workspace, do not let the cookie value be the sole authority: use a signed, expiry-bound state payload that also binds the workspace or account being connected, then require the callback cookie and query state to match before verifying that payload.

Passkeys

Passkeys use Supabase Auth’s experimental WebAuthn APIs. Browser clients created through @tuturuuu/supabase/next/auth-browser must pass auth.experimental.passkey: true; otherwise Supabase rejects auth.signInWithPasskey(), auth.registerPasskey(), and auth.passkey.* calls. apps/web owns passkey UX:
  1. The public login form exposes an explicit “Continue with passkey” action so users can open the browser passkey picker without relying only on autofill.
  2. Account Security in the apps/web settings dialog owns passkey registration, rename, and delete actions. Do not add separate passkey settings pages.
  3. Satellite apps should continue to route user authentication through apps/web cross-app auth. Passkeys are bound to the relying party domain, so the central apps/web origin remains the authority for Tuturuuu account passkeys.
Production Supabase Auth must have passkeys enabled with these relying party settings:
  • Relying Party Display Name: Tuturuuu
  • Relying Party ID: tuturuuu.com
  • Relying Party Origins: https://tuturuuu.com
Local Supabase Auth must also enable passkeys in apps/database/supabase/config.toml. The committed local config uses the Portless apps/web origin:
  • Relying Party Display Name: Tuturuuu
  • Relying Party ID: tuturuuu.localhost
  • Relying Party Origins: https://tuturuuu.localhost
Restart local Supabase after changing this config; the Auth service reads these settings on startup. Real WebAuthn ceremonies require a secure origin, so test registration and sign-in from https://tuturuuu.localhost. UI-only and unsupported-browser paths can still be exercised without registering a credential. Remote Supabase development auth is different: if a cloud Supabase project has captcha protection enabled, passkey sign-in must send a real Turnstile token. The local E2E bypass is honored only when NEXT_PUBLIC_SUPABASE_URL points at local Supabase. When testing remote Supabase from https://tuturuuu.localhost, the Turnstile site key must authorize that local hostname; Cloudflare Turnstile error 110200 means the widget cannot mint the token Supabase requires. The login UI keeps passkey sign-in blocked while that token is missing; either add the local hostname to the Cloudflare Turnstile widget used by the Supabase project, or point NEXT_PUBLIC_SUPABASE_URL at local Supabase for dev passkey testing.

Web QR Session Handoff

QR-based session handoff must never be a public login bootstrap. An unauthenticated browser cannot prove that it belongs to the account scanning the QR code, so public challenge creation would allow QR phishing where an attacker polls a victim-approved challenge and receives the victim’s session. The QR challenge endpoints are therefore constrained to authenticated, same-account handoff:
  1. POST /api/v1/auth/qr-login/challenges validates the request origin and the request-scoped Supabase session before inserting a qr_login_challenges row. The row stores a hashed secret, request metadata, creatorUserId, and a two-minute expiry.
  2. The client renders a tuturuuu://auth/qr-login payload that contains the challenge id, one-time secret, and web origin.
  3. The signed-in mobile app scans the code from Settings > Session. Mobile must have app lock enabled, then performs local authentication before approving.
  4. POST /api/v1/auth/qr-login/challenges/:id/approve validates the mobile Bearer session, challenge secret, and creatorUserId. The approver must be the same user that created the challenge.
  5. The creator polls GET /api/v1/auth/qr-login/challenges/:id?secret=.... Once approved, the server consumes the challenge and creates a fresh detached Supabase session via admin generateLink plus detached verifyOtp.
QR challenge rows never store access or refresh tokens. The table is RLS-enabled without anon/authenticated grants; API routes use the service role for challenge state and the request-scoped client to validate both the challenge creator and the mobile approver.

Sign Out

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";
import { redirect } from "next/navigation";

export async function signOut() {
  const supabase = createClient();
  await supabase.auth.signOut();
  redirect("/login");
}

Server-side Auth Resolution (getClaims first)

For server-side route and helper authorization checks, prefer a claims-first flow:
  1. Call supabase.auth.getClaims() first.
  2. Use claims.sub as the authenticated user id when available.
  3. Fall back to supabase.auth.getUser() when claims are unavailable or insufficient.
This reduces auth latency on hot API paths while preserving correctness for call sites that still need canonical user resolution.
async function resolveCurrentUserId(supabase: TypedSupabaseClient) {
  const getClaims = (supabase.auth as { getClaims?: () => Promise<unknown> })
    .getClaims;

  if (typeof getClaims === "function") {
    const { data, error } = (await getClaims.call(supabase.auth)) as {
      data?: { claims?: { sub?: string } };
      error?: unknown;
    };

    if (!error && data?.claims?.sub) {
      return data.claims.sub;
    }
  }

  const {
    data: { user },
  } = await supabase.auth.getUser();

  return user?.id ?? null;
}
When implementing this pattern, feature-detect getClaims first. Some tests and older stubs only mock getUser, and unconditional getClaims calls will break those environments.

Multi-Factor Authentication (MFA)

Enable TOTP MFA

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function enableMFA() {
  const supabase = createClient();

  // Enroll in MFA
  const { data, error } = await supabase.auth.mfa.enroll({
    factorType: "totp",
  });

  if (error) throw error;

  return {
    qrCode: data.totp.qr_code, // Display to user
    secret: data.totp.secret, // For manual entry
    factorId: data.id,
  };
}

Verify MFA Enrollment

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function verifyMFAEnrollment(factorId: string, code: string) {
  const supabase = createClient();

  const { data, error } = await supabase.auth.mfa.challengeAndVerify({
    factorId,
    code,
  });

  if (error) throw error;
  return { success: true };
}

MFA Challenge During Sign In

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function signInWithMFA(email: string, password: string) {
  const supabase = createClient();

  // Initial sign in
  const { data: signInData, error: signInError } =
    await supabase.auth.signInWithPassword({
      email,
      password,
    });

  if (signInError) throw signInError;

  // Check if MFA is required
  const { data: factors } = await supabase.auth.mfa.listFactors();

  if (factors && factors.totp.length > 0) {
    const factorId = factors.totp[0].id;

    // Create MFA challenge
    const { data: challengeData, error: challengeError } =
      await supabase.auth.mfa.challenge({ factorId });

    if (challengeError) throw challengeError;

    return {
      requiresMFA: true,
      challengeId: challengeData.id,
      factorId,
    };
  }

  return { requiresMFA: false };
}

Verify MFA Code

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function verifyMFACode(
  factorId: string,
  challengeId: string,
  code: string,
) {
  const supabase = createClient();

  const { data, error } = await supabase.auth.mfa.verify({
    factorId,
    challengeId,
    code,
  });

  if (error) throw error;
  return { success: true };
}

Mobile MFA Approval Cookies

Mobile approval for web MFA is scoped to the Supabase login session that created and consumed the approval challenge. When the web browser polls an approved mobile MFA challenge, store the current JWT session_id in the challenge approval metadata. The auth proxy must compare that stored session id with the current request claims before using ttr_mfa_mobile_approval to bypass the MFA redirect. Do not treat the approval cookie as a user-scoped remember-me token. A valid cookie only proves that one challenge secret was approved; it must also match the current Supabase session. Central logout responses should expire the approval cookie, and MFA redirects should clear stale approval cookies that do not satisfy the current-session binding.

Session Management

Get Current Session

import { createClient } from "@tuturuuu/supabase/next/server";

export async function getSession() {
  const supabase = createClient();

  const {
    data: { session },
    error,
  } = await supabase.auth.getSession();

  if (error) throw error;
  return session;
}

Get Current User

import { createClient } from "@tuturuuu/supabase/next/server";

export async function getCurrentUser() {
  const supabase = createClient();

  const {
    data: { user },
    error,
  } = await supabase.auth.getUser();

  if (error || !user) {
    return null;
  }

  return user;
}

Refresh Session

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function refreshSession() {
  const supabase = createClient();

  const { data, error } = await supabase.auth.refreshSession();

  if (error) throw error;
  return data.session;
}

Password Reset

Request Password Reset

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function requestPasswordReset(email: string) {
  const supabase = createClient();

  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/auth/reset-password`,
  });

  if (error) throw error;
  return { success: true };
}

Reset Password

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function resetPassword(newPassword: string) {
  const supabase = createClient();

  const { data, error } = await supabase.auth.updateUser({
    password: newPassword,
  });

  if (error) throw error;
  return { success: true };
}

Email Verification

Resend Verification Email

"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function resendVerificationEmail() {
  const supabase = createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user?.email) {
    throw new Error("No user email found");
  }

  const { error } = await supabase.auth.resend({
    type: "signup",
    email: user.email,
  });

  if (error) throw error;
  return { success: true };
}

Cross-App Authentication

The platform supports token-based authentication across different apps using @tuturuuu/auth/cross-app. When a new satellite app participates in centralized login, wire both sides in the same patch:
  • Register the app URL in packages/utils/src/internal-domains.ts so mapUrlToApp(...) can recognize its returnUrl.
  • Add apps/<app>/src/app/api/auth/verify-app-token/route.ts so /verify-token can exchange the cross-app token for a host-only tuturuuu_app_session cookie. When the satellite requires rewritten apps/web API access, use createPOST('<app>', { verificationBaseUrl: WEB_APP_URL }) so the handoff also stores the Web-issued app-session cookie.
  • Keep generate_cross_app_token(...) bound to the authenticated caller (p_user_id = auth.uid()) so verify endpoints cannot mint sessions for arbitrary users.
  • Do not call supabase.auth.setSession() in registered internal apps. The verifier route sets the HttpOnly app-session cookie, and satellite UI should fetch user/profile data by forwarding that cookie to central internal APIs.
  • Registered internal app source must not call supabase.auth.* directly. Use @tuturuuu/auth/app-session server helpers and @tuturuuu/internal-api profile/default-workspace helpers instead; bun check runs the static guard across all registered app src directories.
  • App-session auth is read/update oriented for satellite apps. Destructive workspace operations such as DELETE /api/workspaces/[wsId] require a full Supabase session (cookie or bearer) and manage_workspace_settings; they do not opt into allowAppSessionAuth because the app-session path uses an admin-backed client that would bypass workspace delete RLS.
If either piece is missing, centralized login can stall on the web login spinner or land on /verify-token with no token-verification endpoint to finish the handoff.

Generate Cross-App Token

import { generateCrossAppToken } from "@tuturuuu/auth/cross-app";

export async function createShortenerLink(userId: string) {
  // Generate token that shortener app can verify
  const token = await generateCrossAppToken(userId, {
    expiresIn: "1h",
    app: "shortener",
  });

  return {
    url: `${process.env.SHORTENER_APP_URL}/create?token=${token}`,
  };
}

Verify Cross-App Token

import { verifyCrossAppToken } from "@tuturuuu/auth/cross-app";

export async function handleCrossAppRequest(token: string) {
  const payload = await verifyCrossAppToken(token);

  if (!payload) {
    throw new Error("Invalid token");
  }

  // payload contains: { userId, app, exp, iat }
  return payload;
}

Middleware Authentication

Protect routes using Next.js middleware:
// middleware.ts
import { createDynamicClient } from "@tuturuuu/supabase/next/server";
import { NextRequest, NextResponse } from "next/server";

export async function proxy(request: NextRequest) {
  const supabase = createDynamicClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  // Protect dashboard routes
  if (request.nextUrl.pathname.startsWith("/dashboard") && !user) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // Redirect authenticated users from auth pages
  if (request.nextUrl.pathname.startsWith("/login") && user) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login", "/signup"],
};

Protected Server Component

import { createClient } from '@tuturuuu/supabase/next/server';
import { redirect } from 'next/navigation';

export default async function ProtectedPage() {
  const supabase = createClient();

  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    redirect('/login');
  }

  return (
    <div>
      <h1>Welcome, {user.email}</h1>
    </div>
  );
}

Client-Side Authentication

useUser Hook

"use client";

import { createClient } from "@tuturuuu/supabase/next/client";
import { useEffect, useState } from "react";
import type { SupabaseUser } from "@tuturuuu/supabase/next/user";

export function useUser() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const supabase = createClient();

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });

    // Listen for auth changes
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null);
    });

    return () => subscription.unsubscribe();
  }, []);

  return { user, loading };
}

Usage

"use client";

import { useUser } from "@/hooks/useUser";

export function UserProfile() {
  const { user, loading } = useUser();

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>Not authenticated</div>;

  return <div>Logged in as {user.email}</div>;
}

Identity Linking

Link multiple auth providers to same account:
"use server";

import { createClient } from "@tuturuuu/supabase/next/server";

export async function linkIdentity(provider: "google" | "github") {
  const supabase = createClient();

  const { data, error } = await supabase.auth.linkIdentity({
    provider,
  });

  if (error) throw error;
  return { url: data.url };
}

Best Practices

✅ DO

  1. Always check authentication server-side
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (!user) throw new Error("Unauthorized");
    
  2. Redirect after authentication
    redirect("/dashboard"); // Don't return sensitive data
    
  3. Handle errors gracefully
    if (error) return { error: error.message };
    
  4. Implement MFA for sensitive operations
    const mfaRequired = await checkMFAEnabled(userId);
    

❌ DON’T

  1. Don’t trust client-side auth state alone
    // ❌ Bad
    if (localStorage.getItem("user")) {
      /* ... */
    }
    
  2. Don’t expose sensitive data in auth redirects
    // ❌ Bad
    redirect(`/dashboard?apiKey=${apiKey}`);
    
  3. Don’t store passwords
    // ❌ Bad: Let Supabase handle password storage
    
  4. Don’t use createAdminClient() for auth operations
    // ❌ Bad: Use regular client
    const sbAdmin = await createAdminClient();
    await sbAdmin.auth.signUp({ ... });
    

External Resources