Auth Security

Authentication

User authentication with SSR support, zero loading flash, and declarative components.

Reading Auth State

<script setup lang="ts">
const { isAuthenticated, user, isPending } = useConvexAuth()
</script>

<template>
  <div v-if="isPending">Checking auth...</div>
  <div v-else-if="isAuthenticated">
    Welcome, {{ user?.name }}!
  </div>
  <div v-else>
    <NuxtLink to="/auth/signin">Sign In</NuxtLink>
  </div>
</template>

SSR Auth Flow

Authentication is pre-populated during SSR for instant authenticated rendering:

1. Browser Request
   Cookie: session_token=xxx
          |
          v
2. SSR Plugin (server)
   - Read session cookie
   - Exchange for JWT via Better Auth
   - Fetch user data
   - Store in state for hydration
          |
          v
3. HTML Response
   - Contains pre-populated auth state
          |
          v
4. Client Hydration
   - Receives auth state (no flash!)
   - Initializes ConvexClient with token
   - Starts WebSocket subscription

Key benefit: Users see authenticated content immediately, no flash of unauthenticated state.

CSR-Only Mode

When running with ssr: false in your Nuxt config, the module automatically detects this and fetches auth state client-side:

1. Browser loads page (no SSR)
          |
          v
2. Client Plugin
   - Detects CSR-only mode
   - Fetches token from /api/auth/convex/token
   - Extracts user from JWT
   - Initializes ConvexClient with token
CSR mode requires one additional request to check auth status. This is unavoidable since HttpOnly cookies cannot be read by JavaScript.

Auth Operations

useAuthClient

Access the Better Auth client for sign-in, sign-up, and sign-out operations.

<script setup lang="ts">
const authClient = useAuthClient()

async function signInWithEmail(email: string, password: string) {
  const { data, error } = await authClient.signIn.email({
    email,
    password,
  })

  if (error) {
    console.error('Sign in failed:', error.message)
  } else {
    navigateTo('/dashboard')
  }
}

async function signOut() {
  await authClient.signOut()
  navigateTo('/')
}
</script>
Returns null during SSR. Auth operations are client-only.

Email/Password Sign In

<script setup lang="ts">
const authClient = useAuthClient()

const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)

async function handleSignIn() {
  error.value = null
  loading.value = true

  try {
    const { error: authError } = await authClient.signIn.email({
      email: email.value,
      password: password.value,
    })

    if (authError) {
      error.value = authError.message
    } else {
      navigateTo('/dashboard')
    }
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSignIn">
    <input v-model="email" type="email" placeholder="Email" />
    <input v-model="password" type="password" placeholder="Password" />
    <button :disabled="loading">
      {{ loading ? 'Signing in...' : 'Sign In' }}
    </button>
    <p v-if="error" class="error">{{ error }}</p>
  </form>
</template>

OAuth Sign In

<script setup lang="ts">
const authClient = useAuthClient()

async function signInWithGoogle() {
  await authClient.signIn.social({
    provider: 'google',
    callbackURL: '/dashboard',
  })
}

async function signInWithGitHub() {
  await authClient.signIn.social({
    provider: 'github',
    callbackURL: '/dashboard',
  })
}
</script>

<template>
  <div class="oauth-buttons">
    <button @click="signInWithGoogle">
      Continue with Google
    </button>
    <button @click="signInWithGitHub">
      Continue with GitHub
    </button>
  </div>
</template>

Sign Up

<script setup lang="ts">
const authClient = useAuthClient()

const name = ref('')
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)

async function handleSignUp() {
  const { error: authError } = await authClient.signUp.email({
    name: name.value,
    email: email.value,
    password: password.value,
  })

  if (authError) {
    error.value = authError.message
  } else {
    navigateTo('/dashboard')
  }
}
</script>

<template>
  <form @submit.prevent="handleSignUp">
    <input v-model="name" placeholder="Name" />
    <input v-model="email" type="email" placeholder="Email" />
    <input v-model="password" type="password" placeholder="Password" />
    <button>Create Account</button>
    <p v-if="error" class="error">{{ error }}</p>
  </form>
</template>

Sign Out

<script setup lang="ts">
const authClient = useAuthClient()
const { isAuthenticated, user } = useConvexAuth()

async function handleSignOut() {
  await authClient.signOut()
  navigateTo('/')
}
</script>

<template>
  <div v-if="isAuthenticated" class="user-menu">
    <span>{{ user?.name }}</span>
    <button @click="handleSignOut">Sign Out</button>
  </div>
</template>

Auth Components

Declarative components for rendering content based on authentication state.

ComponentShows content when...
<ConvexAuthLoading>Auth state is being determined
<ConvexAuthenticated>User is authenticated
<ConvexUnauthenticated>User is NOT authenticated

Basic Usage

<template>
  <ConvexAuthLoading>
    <div class="loading">Checking authentication...</div>
  </ConvexAuthLoading>

  <ConvexAuthenticated>
    <Dashboard />
  </ConvexAuthenticated>

  <ConvexUnauthenticated>
    <LoginPrompt />
  </ConvexUnauthenticated>
</template>

ConvexAuthLoading

Renders content while auth state is being determined.

<template>
  <ConvexAuthLoading>
    <div class="auth-loading">
      <Spinner />
      <p>Loading...</p>
    </div>
  </ConvexAuthLoading>
</template>

ConvexAuthenticated

Renders content when user is authenticated.

<template>
  <ConvexAuthenticated>
    <nav class="user-nav">
      <NuxtLink to="/dashboard">Dashboard</NuxtLink>
      <NuxtLink to="/settings">Settings</NuxtLink>
      <UserMenu />
    </nav>
  </ConvexAuthenticated>
</template>

ConvexUnauthenticated

Renders content when user is NOT authenticated.

<template>
  <ConvexUnauthenticated>
    <div class="guest-nav">
      <NuxtLink to="/auth/signin">Sign In</NuxtLink>
      <NuxtLink to="/auth/signup">Sign Up</NuxtLink>
    </div>
  </ConvexUnauthenticated>
</template>

Patterns

Header with Auth States

<template>
  <header class="app-header">
    <NuxtLink to="/" class="logo">MyApp</NuxtLink>

    <nav>
      <ConvexAuthLoading>
        <div class="skeleton-avatar" />
      </ConvexAuthLoading>

      <ConvexAuthenticated>
        <NuxtLink to="/dashboard">Dashboard</NuxtLink>
        <UserDropdown />
      </ConvexAuthenticated>

      <ConvexUnauthenticated>
        <NuxtLink to="/auth/signin">Sign In</NuxtLink>
        <NuxtLink to="/auth/signup" class="btn-primary">
          Get Started
        </NuxtLink>
      </ConvexUnauthenticated>
    </nav>
  </header>
</template>

Protected Page Layout

layouts/dashboard.vue
<template>
  <div class="dashboard-layout">
    <ConvexAuthLoading>
      <div class="loading-screen">
        <Spinner size="lg" />
        <p>Loading your dashboard...</p>
      </div>
    </ConvexAuthLoading>

    <ConvexAuthenticated>
      <DashboardSidebar />
      <main class="dashboard-content">
        <slot />
      </main>
    </ConvexAuthenticated>

    <ConvexUnauthenticated>
      <div class="auth-required">
        <h1>Authentication Required</h1>
        <p>Please sign in to access this page.</p>
        <NuxtLink to="/auth/signin" class="btn">Sign In</NuxtLink>
      </div>
    </ConvexUnauthenticated>
  </div>
</template>

Protected Page with Redirect

<script setup lang="ts">
const { isAuthenticated, isPending } = useConvexAuth()

// Redirect if not authenticated
watch(
  () => ({ isAuthenticated: isAuthenticated.value, isPending: isPending.value }),
  ({ isAuthenticated, isPending }) => {
    if (!isPending && !isAuthenticated) {
      navigateTo('/auth/signin')
    }
  },
  { immediate: true }
)
</script>

<template>
  <div v-if="isPending">Loading...</div>
  <div v-else-if="isAuthenticated">
    <Dashboard />
  </div>
</template>

Conditional Query Based on Auth

<script setup lang="ts">
const { isAuthenticated } = useConvexAuth()

// Only fetch when authenticated
const { data: profile } = useConvexQuery(
  api.users.getProfile,
  computed(() => isAuthenticated.value ? {} : 'skip')
)
</script>

Conditional Feature

<template>
  <div class="comments-section">
    <h2>Comments</h2>

    <!-- Anyone can read comments -->
    <CommentList :postId="postId" />

    <!-- Only authenticated users can post -->
    <ConvexAuthenticated>
      <CommentForm :postId="postId" />
    </ConvexAuthenticated>

    <ConvexUnauthenticated>
      <p class="signin-prompt">
        <NuxtLink to="/auth/signin">Sign in</NuxtLink>
        to leave a comment.
      </p>
    </ConvexUnauthenticated>
  </div>
</template>

With SSR and ClientOnly

For proper SSR handling, wrap auth components in <ClientOnly>:

<template>
  <ClientOnly>
    <ConvexAuthLoading>
      <SkeletonHeader />
    </ConvexAuthLoading>

    <ConvexAuthenticated>
      <AuthenticatedHeader />
    </ConvexAuthenticated>

    <ConvexUnauthenticated>
      <GuestHeader />
    </ConvexUnauthenticated>

    <template #fallback>
      <SkeletonHeader />
    </template>
  </ClientOnly>
</template>

User Welcome Message

<script setup lang="ts">
const { user } = useConvexAuth()
</script>

<template>
  <ConvexAuthenticated>
    <div class="welcome">
      <img
        v-if="user?.image"
        :src="user.image"
        :alt="user.name"
        class="avatar"
      />
      <div>
        <p class="greeting">Welcome back,</p>
        <p class="name">{{ user?.name }}</p>
      </div>
    </div>
  </ConvexAuthenticated>
</template>

Composable vs Components

Auth components are syntax sugar over useConvexAuth():

<!-- Using components -->
<template>
  <ConvexAuthenticated>
    <Dashboard />
  </ConvexAuthenticated>
</template>

<!-- Equivalent with composable -->
<script setup>
const { isAuthenticated, isPending } = useConvexAuth()
</script>
<template>
  <Dashboard v-if="!isPending && isAuthenticated" />
</template>

When to use components:

  • Cleaner template syntax
  • Multiple auth states in one template
  • Layouts and common patterns

When to use composable:

  • Need access to user or token
  • Complex conditional logic
  • Programmatic auth checks

Complete Auth Page Example

pages/auth/signin.vue
<script setup lang="ts">
const authClient = useAuthClient()
const { isAuthenticated } = useConvexAuth()

// Redirect if already authenticated
watch(isAuthenticated, (value) => {
  if (value) navigateTo('/dashboard')
}, { immediate: true })

const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)

async function handleEmailSignIn() {
  error.value = null
  loading.value = true

  try {
    const { error: authError } = await authClient.signIn.email({
      email: email.value,
      password: password.value,
    })
    if (authError) {
      error.value = authError.message
    }
  } finally {
    loading.value = false
  }
}

async function handleGoogleSignIn() {
  await authClient.signIn.social({
    provider: 'google',
    callbackURL: '/dashboard',
  })
}
</script>

<template>
  <div class="auth-page">
    <h1>Sign In</h1>

    <form @submit.prevent="handleEmailSignIn">
      <input
        v-model="email"
        type="email"
        placeholder="Email"
        required
      />
      <input
        v-model="password"
        type="password"
        placeholder="Password"
        required
      />
      <button type="submit" :disabled="loading">
        {{ loading ? 'Signing in...' : 'Sign In' }}
      </button>
    </form>

    <p v-if="error" class="error">{{ error }}</p>

    <div class="divider">or</div>

    <button @click="handleGoogleSignIn" class="oauth-btn">
      Continue with Google
    </button>

    <p class="signup-link">
      Don't have an account?
      <NuxtLink to="/auth/signup">Sign Up</NuxtLink>
    </p>
  </div>
</template>

API Reference

useConvexAuth Returns

PropertyTypeDescription
tokenReadonly<Ref<string | null>>JWT token for Convex auth
userReadonly<Ref<ConvexUser | null>>Authenticated user data
isAuthenticatedComputedRef<boolean>True when user is logged in
isPendingReadonly<Ref<boolean>>True during auth operations

ConvexUser Type

interface ConvexUser {
  id: string
  name: string
  email: string
  emailVerified?: boolean
  image?: string
  createdAt?: string
  updatedAt?: string
}

useAuthClient Returns

ReturnTypeDescription
authClientAuthClient | nullBetter Auth client instance

Notes

  • Components use useConvexAuth() internally
  • Auth state is pre-populated during SSR (no flash)
  • Components render nothing (not even a wrapper element) when condition isn't met
  • All three components can coexist in the same template

Skipping Auth Checks

For marketing pages that never need authentication, you can skip auth checks entirely to avoid unnecessary requests.

Config-Based Skip

Skip auth for entire route patterns in nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  convex: {
    url: process.env.CONVEX_URL,
    skipAuthRoutes: [
      '/',           // Home page
      '/pricing',    // Pricing page
      '/about',      // About page
      '/docs/**',    // All docs pages
      '/blog/**',    // All blog pages
    ]
  }
})

Supported patterns:

  • Exact match: /about matches only /about
  • Single wildcard: /blog/* matches /blog/post but not /blog/post/comments
  • Double wildcard: /docs/** matches /docs, /docs/guide, /docs/guide/auth

Page-Level Skip

Skip auth for individual pages using definePageMeta:

pages/landing.vue
<script setup lang="ts">
definePageMeta({
  skipConvexAuth: true
})
</script>

<template>
  <div>
    <h1>Welcome to Our App</h1>
    <!-- Auth checks skipped for this page -->
  </div>
</template>

When to Skip Auth

Skip authDon't skip auth
Marketing/landing pagesDashboard
Public blog postsUser settings
DocumentationProtected content
Pricing pageAdmin panels
Skipping auth saves one API request per page load, reducing e.g. Vercel function invocations and Convex database reads.