Skip to main content
Umbra uses Supabase for authentication and user management, providing email/password sign-in, magic links, and role-based access control.

Overview

The authentication system integrates Supabase Auth with Next.js 15’s app router, providing:
  • Email/password authentication for returning users
  • Magic link activation for waitlist approvals
  • Role-based access control (admin, member, guest)
  • Server-side session management via middleware
  • Client-side state synchronization via SupabaseAuthListener
All authentication logic uses Supabase’s server-side rendering (SSR) package (@supabase/ssr) to maintain consistent auth state between server components, client components, and middleware.

Supabase Integration

Umbra initializes Supabase clients for different contexts:

Client Contexts

Browser Client

Client-side auth for React components. Initialized with public anon key.

Server Components

Server-side auth for React Server Components. Uses cookies for sessions.

Route Handlers

API route auth for Next.js route handlers. Manages session cookies.

Service Role

Admin client with full database access. Used for waitlist management.

Client Initialization

Browser client:
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from './types'

export function createSupabaseBrowserClient() {
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
  const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  
  if (!supabaseUrl || !supabaseKey) {
    throw new Error('Missing Supabase environment variables')
  }
  
  return createBrowserClient<Database>(supabaseUrl, supabaseKey)
}
Route handler client:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createSupabaseRouteHandlerClient() {
  const cookieStore = await cookies()
  
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name) {
          return cookieStore.get(name)?.value
        },
        set(name, value, options) {
          cookieStore.set(name, value, options)
        },
        remove(name, options) {
          cookieStore.delete(name)
        },
      },
    }
  )
}

Waitlist Flow

Umbra uses a waitlist system to control access during beta:
1

Waitlist Request

User submits email, company, and use case through /sign-in or landing page waitlist form. Request is stored in the waitlist_requests table with status requested.
2

Admin Review

Admin views request in /admin/waitlist console, adds notes, and sets priority/status.
3

Activation

Admin clicks “Send Invitation” to generate a magic link and send branded email via Resend. Status changes to invited.
4

Email Verification

User clicks magic link, which confirms their email and triggers the activation trigger.
5

Auto-Promotion

Database trigger promotes user to member role and updates waitlist status to activated.

Waitlist Request Storage

Waitlist entries are stored in PostgreSQL:
create type public.waitlist_status as enum (
  'requested',   -- Initial submission
  'contacted',   -- Admin reached out manually
  'invited',     -- Magic link sent
  'activated',   -- User verified email
  'archived'     -- Closed/declined
);

create table public.waitlist_requests (
  id uuid primary key default gen_random_uuid(),
  created_at timestamptz not null default timezone('utc', now()),
  email citext not null unique,
  company text,
  use_case text,
  status public.waitlist_status not null default 'requested',
  notes text,  -- Admin notes
  priority integer,  -- Admin priority
  last_contacted_at timestamptz,
  supabase_user_id uuid references auth.users (id) on delete set null,
  activation_sent_at timestamptz,
  activation_link text,
  activated_at timestamptz,
  metadata jsonb
);

Activation Trigger

The handle_waitlist_activation() trigger automatically promotes users:
create or replace function public.handle_waitlist_activation()
returns trigger
language plpgsql
security definer
set search_path = public, auth
as $$
declare
  waitlist_entry public.waitlist_requests%rowtype;
  merged_roles jsonb;
  app_meta jsonb;
  user_meta jsonb;
begin
  -- Only fire when email is confirmed
  if NEW.email_confirmed_at is null then
    return NEW;
  end if;

  -- Find matching waitlist entry with status 'invited'
  select * into waitlist_entry
    from public.waitlist_requests
   where email = NEW.email::citext
     and status = 'invited'
   limit 1;

  if not found then
    return NEW;
  end if;

  -- Update waitlist status to 'activated'
  update public.waitlist_requests
     set status = 'activated',
         supabase_user_id = NEW.id,
         activated_at = coalesce(activated_at, timezone('utc', now()))
   where id = waitlist_entry.id;

  -- Add 'member' role to user metadata
  app_meta := coalesce(NEW.raw_app_meta_data, '{}'::jsonb);
  merged_roles := (
    select coalesce(jsonb_agg(role order by role), jsonb_build_array('member'))
    from (
      select distinct role
      from (
        select jsonb_array_elements_text(coalesce(app_meta->'roles', '[]'::jsonb)) as role
        union all
        select 'member'
      ) roles
    ) distinct_roles
  );
  app_meta := jsonb_set(app_meta, '{roles}', merged_roles, true);
  NEW.raw_app_meta_data := app_meta;

  return NEW;
end;
$$;
Magic links provide passwordless authentication for waitlist users: The /api/admin/waitlist/[id]/activate endpoint generates magic links:
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
  // Verify admin role
  const supabase = await createSupabaseRouteHandlerClient()
  await requireAdminUser(supabase)

  // Load waitlist entry
  const serviceRole = createSupabaseServiceRoleClient()
  const { data: entry } = await serviceRole
    .from("waitlist_requests")
    .select("*")
    .eq("id", entryId)
    .maybeSingle()

  // Generate magic link
  const appUrl = resolveAppUrl(request)
  const redirectTo = `${appUrl}/sign-in?redirect=/confidential-ai`

  const { data: linkData } = await serviceRole.auth.admin.generateLink({
    type: "magiclink",
    email: entry.email,
    options: {
      redirectTo,
      data: {
        waitlist_request_id: entry.id,
        company: entry.company ?? undefined,
        use_case: entry.use_case ?? undefined,
      },
    },
  })

  const magicLink = linkData?.properties?.action_link

  // Send email via Resend
  await sendWaitlistActivationEmail({
    email: entry.email,
    magicLink,
    company: entry.company,
    useCase: entry.use_case,
  })

  // Update waitlist entry
  await serviceRole
    .from("waitlist_requests")
    .update({
      status: "invited",
      activation_sent_at: new Date().toISOString(),
      activation_link: magicLink,
    })
    .eq("id", entryId)

  return NextResponse.json({ success: true })
}

Email Template

Activation emails are sent via Resend using branded HTML templates:
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export async function sendWaitlistActivationEmail({
  email,
  magicLink,
  company,
  useCase,
}: {
  email: string
  magicLink: string
  company?: string | null
  useCase?: string | null
}) {
  const subject = 'Welcome to Umbra - Your Access is Ready'
  
  const htmlBody = `
    <h1>Welcome to Umbra</h1>
    <p>Your access to Umbra's confidential AI workspace has been approved.</p>
    <p><a href="${magicLink}">Click here to activate your account</a></p>
    ${company ? `<p>Company: ${company}</p>` : ''}
    ${useCase ? `<p>Use case: ${useCase}</p>` : ''}
  `
  
  const textBody = `
    Welcome to Umbra
    
    Your access has been approved. Activate your account:
    ${magicLink}
  `

  await resend.emails.send({
    from: process.env.RESEND_FROM_EMAIL ?? 'Concrete Security <[email protected]>',
    to: email,
    subject,
    html: htmlBody,
    text: textBody,
  })
}

Role-Based Access Control

Umbra uses roles stored in Supabase user metadata:

Role Types

RoleAccess
adminFull access to admin console, waitlist management, and all features
memberAccess to confidential workspace without guest restrictions
(none)Guest access with optional throttling if NEXT_PUBLIC_CONFIDENTIAL_ENABLE_GUEST_LIMITS=true

Role Storage

Roles are stored in auth.users.raw_app_meta_data:
update auth.users
set raw_app_meta_data = jsonb_set(
  coalesce(raw_app_meta_data, '{}'),
  '{roles}',
  '["admin"]'::jsonb,
  true
)
where email = '[email protected]';

Role Checking

The requireAdminUser() helper enforces admin access:
import type { SupabaseClient, User } from "@supabase/supabase-js"
import { isAuthSessionMissingError } from "@/lib/supabase/errors"

export async function requireAdminUser(client: SupabaseClient): Promise<User> {
  const { data, error } = await client.auth.getUser()

  if (error || !data?.user) {
    if (isAuthSessionMissingError(error)) {
      throw new AuthenticatedAccessError("Authentication required", 401)
    }
    throw new AuthenticatedAccessError(error?.message ?? "Unauthorized", 403)
  }

  const roles = (data.user.app_metadata?.roles as string[] | undefined) ?? []
  if (!roles.includes("admin")) {
    throw new AuthenticatedAccessError("Administrator role required", 403)
  }

  return data.user
}
Admin routes use this helper:
export async function GET(request: Request) {
  const supabase = await createSupabaseRouteHandlerClient()
  
  try {
    await requireAdminUser(supabase)
  } catch (error) {
    if (error instanceof AuthenticatedAccessError) {
      return NextResponse.json({ error: error.message }, { status: error.status })
    }
    throw error
  }
  
  // Admin logic here...
}

Guest Throttling

Optional guest throttling limits anonymous usage:

Configuration

NEXT_PUBLIC_CONFIDENTIAL_ENABLE_GUEST_LIMITS=true

Implementation

const GUEST_USAGE_STORAGE_KEY = "confidential-chat-guest-used"
const GUEST_ACTIVE_SESSION_KEY = "confidential-chat-guest-active"
const GUEST_LIMITS_ENABLED = process.env.NEXT_PUBLIC_CONFIDENTIAL_ENABLE_GUEST_LIMITS === "true"

useEffect(() => {
  if (!GUEST_LIMITS_ENABLED || authState !== "signed-out") {
    setGuestUsageRestricted(false)
    return
  }

  const alreadyUsed = localStorage.getItem(GUEST_USAGE_STORAGE_KEY)
  const activeSession = sessionStorage.getItem(GUEST_ACTIVE_SESSION_KEY)
  const locked = Boolean(alreadyUsed && !activeSession)
  
  setGuestUsageRestricted(locked)
  setGuestNotice(
    locked ? "You've already used your guest session. Sign in to continue." : null
  )
}, [authState])
Guest throttling only applies when Supabase is configured and NEXT_PUBLIC_CONFIDENTIAL_ENABLE_GUEST_LIMITS=true. If Supabase is unavailable, guests have unlimited access.

Middleware and Session Management

The middleware.ts file refreshes Supabase sessions on every request:
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()
  
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name) {
          return request.cookies.get(name)?.value
        },
        set(name, value, options) {
          response.cookies.set(name, value, options)
        },
        remove(name, options) {
          response.cookies.delete(name)
        },
      },
    }
  )
  
  // Refresh session
  await supabase.auth.getUser()
  
  return response
}
The SupabaseAuthListener component keeps client state synchronized:
export function SupabaseAuthListener() {
  const supabase = createSupabaseBrowserClient()
  
  useEffect(() => {
    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      (event, session) => {
        // Post to /auth/callback to sync server state
        fetch('/auth/callback', {
          method: 'POST',
          body: JSON.stringify({ event, session }),
        })
      }
    )
    
    return () => subscription.unsubscribe()
  }, [supabase])
  
  return null
}

Configuration Reference

Environment Variables

VariableRequiredDescription
NEXT_PUBLIC_SUPABASE_URLYesSupabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEYYesPublic anon key for client-side auth
SUPABASE_SERVICE_ROLE_KEYYesService role key for admin operations
NEXT_PUBLIC_APP_URLRecommendedCanonical origin for magic links
RESEND_API_KEYYesResend API key for email delivery
RESEND_FROM_EMAILOptionalOverride sender email
NEXT_PUBLIC_CONFIDENTIAL_ENABLE_GUEST_LIMITSOptionalEnable guest throttling

Supabase Setup

1

Create Project

Create a Supabase project at https://supabase.com
2

Configure URLs

Add callback URLs in Authentication → URL Configuration:
  • http://localhost:3000/auth/callback (dev)
  • https://yourdomain.com/auth/callback (prod)
3

Run Schema

Execute supabase/schema.sql in the SQL editor to create waitlist_requests table and trigger.
4

Create Admin User

Manually promote a user to admin:
update auth.users
set raw_app_meta_data = jsonb_set(
  coalesce(raw_app_meta_data, '{}'),
  '{roles}',
  '["admin"]'::jsonb,
  true
)
where email = '[email protected]';

Admin Console

Learn how to manage waitlist requests and activate users

Confidential Workspace

Understand how authentication integrates with the workspace

Build docs developers (and LLMs) love