ScaleRocket/Web

Authentication

How authentication works in ScaleRocket — email/password, OAuth, session management, and protected routes.

Authentication

ScaleRocket handles all authentication through the dashboard app (apps/app) — login, signup, password reset, and OAuth flows. Protected routes are enforced using TanStack Router.

How Auth Works

  1. User signs up or logs in via the dashboard app
  2. Supabase Auth creates a session and returns a JWT
  3. The Supabase client stores the session in localStorage
  4. Every API request includes the JWT for authorization
  5. Edge Functions verify the JWT to identify the user
  6. Row Level Security in PostgreSQL uses the JWT to filter data

How Auth Works

  1. User signs up or logs in via the dashboard app
  2. Convex Auth creates a session and stores it server-side
  3. The Convex client automatically manages the auth token
  4. Every Convex query/mutation is authenticated automatically
  5. Server functions use getAuthUserId(ctx) to identify the user
  6. Convex's permission system controls data access per function

Email/Password Signup

The default signup flow:

  1. User fills in email + password on /signup
  2. The app calls supabase.auth.signUp()
  3. Supabase sends a confirmation email
  4. User clicks the confirmation link
  5. User is redirected back to the app as authenticated
// apps/app/src/lib/auth.ts
import { supabase } from "./supabase";

export async function signUp(email: string, password: string) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${window.location.origin}/auth/callback`,
    },
  });

  if (error) throw error;
  return data;
}

export async function signIn(email: string, password: string) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) throw error;
  return data;
}
  1. User fills in email + password on /signup
  2. The app calls signIn("password", formData) with flow: "signUp"
  3. Convex Auth sends a verification email
  4. User clicks the verification link
  5. User is redirected back to the app as authenticated
// apps/app/src/lib/auth.ts
import { useAuthActions } from "@convex-dev/auth/react";

// Inside a React component
const { signIn } = useAuthActions();

// Sign up
async function handleSignUp(email: string, password: string) {
  const formData = new FormData();
  formData.set("email", email);
  formData.set("password", password);
  formData.set("flow", "signUp");
  await signIn("password", formData);
}

// Sign in
async function handleSignIn(email: string, password: string) {
  const formData = new FormData();
  formData.set("email", email);
  formData.set("password", password);
  formData.set("flow", "signIn");
  await signIn("password", formData);
}

OAuth Setup (Google, GitHub)

ScaleRocket supports OAuth providers out of the box.

After configuring the providers in Supabase (see Supabase Setup), the client code is simple:

// apps/app/src/lib/auth.ts
export async function signInWithGoogle() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${window.location.origin}/auth/callback`,
    },
  });

  if (error) throw error;
  return data;
}

export async function signInWithGitHub() {
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "github",
    options: {
      redirectTo: `${window.location.origin}/auth/callback`,
    },
  });

  if (error) throw error;
  return data;
}

After configuring the providers in Convex Auth, the client code is simple:

// apps/app/src/lib/auth.ts
import { useAuthActions } from "@convex-dev/auth/react";

// Inside a React component
const { signIn } = useAuthActions();

// Google OAuth
async function signInWithGoogle() {
  await signIn("google");
}

// GitHub OAuth
async function signInWithGitHub() {
  await signIn("github");
}

Auth Callback Page

After OAuth redirect, the callback page exchanges the code for a session:

// apps/app/src/routes/auth/callback.tsx
import { useEffect } from "react";
import { useNavigate } from "@tanstack/react-router";
import { supabase } from "../../lib/supabase";

export default function AuthCallback() {
  const navigate = useNavigate();

  useEffect(() => {
    supabase.auth.onAuthStateChange((event) => {
      if (event === "SIGNED_IN") {
        navigate({ to: "/dashboard" });
      }
    });
  }, [navigate]);

  return <div>Completing sign in...</div>;
}

With Convex Auth, OAuth callbacks are handled automatically. No callback page is needed — the user is redirected back to the app and the session is established by the Convex client.

Password Reset

ScaleRocket includes a complete password reset flow:

// Request password reset
export async function resetPassword(email: string) {
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${window.location.origin}/auth/reset-password`,
  });

  if (error) throw error;
}

// Update password (on the reset page)
export async function updatePassword(newPassword: string) {
  const { error } = await supabase.auth.updateUser({
    password: newPassword,
  });

  if (error) throw error;
}

The flow:

  1. User enters their email on /forgot-password
  2. Supabase sends a reset email with a magic link
  3. User clicks the link, lands on /auth/reset-password
  4. User enters a new password
  5. The app calls updateUser() to set the new password

ScaleRocket includes a complete password reset flow:

import { useAuthActions } from "@convex-dev/auth/react";

const { signIn } = useAuthActions();

// Request password reset
async function resetPassword(email: string) {
  const formData = new FormData();
  formData.set("email", email);
  formData.set("flow", "reset");
  await signIn("password", formData);
}

// The reset code/link is sent via email.
// When the user submits the new password with the reset token:
async function updatePassword(code: string, newPassword: string) {
  const formData = new FormData();
  formData.set("code", code);
  formData.set("newPassword", newPassword);
  formData.set("flow", "reset-verification");
  await signIn("password", formData);
}

The flow:

  1. User enters their email on /forgot-password
  2. Convex Auth sends a reset email with a code/link
  3. User clicks the link or enters the code
  4. User enters a new password
  5. The app calls signIn("password", formData) with the reset verification flow

Session Management

Supabase handles session refresh automatically. You can listen for auth state changes:

// apps/app/src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
);
// Listen for auth changes anywhere in the app
supabase.auth.onAuthStateChange((event, session) => {
  if (event === "SIGNED_OUT") {
    // Redirect to login
  }
  if (event === "TOKEN_REFRESHED") {
    // Session was refreshed automatically
  }
});

Get the current user:

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

Sign out:

await supabase.auth.signOut();

Convex handles sessions automatically. Use the useConvexAuth() hook to check auth state:

import { useConvexAuth } from "convex/react";

function MyComponent() {
  const { isAuthenticated, isLoading } = useConvexAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!isAuthenticated) return <div>Not logged in</div>;

  return <div>Welcome!</div>;
}

Get the current user on the server side:

import { getAuthUserId } from "@convex-dev/auth/server";

// Inside a Convex query or mutation
const userId = await getAuthUserId(ctx);

Sign out:

import { useAuthActions } from "@convex-dev/auth/react";

const { signOut } = useAuthActions();
await signOut();

Protected Routes (TanStack Router)

ScaleRocket uses TanStack Router with a beforeLoad guard to protect routes:

// apps/app/src/routes/__root.tsx
import { createRootRoute } from "@tanstack/react-router";
import { supabase } from "../lib/supabase";

export const Route = createRootRoute({
  beforeLoad: async ({ location }) => {
    const { data: { session } } = await supabase.auth.getSession();

    const publicRoutes = ["/login", "/signup", "/forgot-password", "/auth/callback"];
    const isPublicRoute = publicRoutes.some((route) =>
      location.pathname.startsWith(route)
    );

    if (!session && !isPublicRoute) {
      throw redirect({ to: "/login" });
    }

    if (session && (location.pathname === "/login" || location.pathname === "/signup")) {
      throw redirect({ to: "/dashboard" });
    }
  },
});
// apps/app/src/components/AuthGuard.tsx
import { useConvexAuth } from "convex/react";
import { Navigate } from "@tanstack/react-router";

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const { isAuthenticated, isLoading } = useConvexAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!isAuthenticated) return <Navigate to="/login" />;

  return <>{children}</>;
}

Wrap your protected route layouts with AuthGuard to enforce authentication.

This ensures:

  • Unauthenticated users are redirected to /login
  • Authenticated users are redirected away from login/signup pages
  • Public routes (login, signup, callback) are always accessible

User Profile

After signup, ScaleRocket creates a profile for the user:

A row is created in the profiles table via a database trigger:

-- supabase/migrations/create_profile_trigger.sql
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
  INSERT INTO public.profiles (id, email, full_name, avatar_url)
  VALUES (
    NEW.id,
    NEW.email,
    NEW.raw_user_meta_data->>'full_name',
    NEW.raw_user_meta_data->>'avatar_url'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

A profile is created via a getOrCreateProfile mutation, called automatically after authentication:

// convex/users.ts
import { mutation, query } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";

export const getOrCreateProfile = mutation({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not authenticated");

    const existing = await ctx.db
      .query("profiles")
      .withIndex("by_userId", (q) => q.eq("userId", userId))
      .unique();

    if (existing) return existing._id;

    const user = await ctx.db.get(userId);
    return await ctx.db.insert("profiles", {
      userId,
      email: user?.email ?? "",
      fullName: user?.name ?? "",
      avatarUrl: user?.image ?? "",
    });
  },
});

Next Steps

Done reading? Mark this page as complete.

On this page