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.

Local Development and CORS

For localhost development, keep auth requests same-origin through the Nuxt proxy (/api/auth/*) and point the module at your dev Convex HTTP Actions URL.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ["better-convex-nuxt"],
  convex: {
    url: process.env.CONVEX_URL,
    siteUrl:
      process.env.NUXT_PUBLIC_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL,
  },
});
.env.local
CONVEX_URL=https://your-dev-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-dev-deployment.convex.site
SITE_URL=http://localhost:3000

This avoids browser CORS preflight failures caused by cross-origin auth requests to production domains.

better-convex-nuxtv0.2.11+ follows only canonical host redirects server-side (same path/query, different host) and forwards OAuth redirects to the browser.
When using the Nuxt auth proxy (/api/auth/*), set convex.siteUrl in nuxt.config to your Convex HTTP Actions host (*.convex.site), but set Better Auth baseURL in convex/auth.ts to your app URL (SITE_URL). Redirect handling improved in v0.2.11+, but social OAuth callbacks still depend on the correct baseURL.

Auth Operations

useAuthClient

Access the Better Auth client for sign-in and sign-up operations. Use useConvexAuth().signOut() for logout so Convex auth state clears reactively.

<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");
  }
}

const { signOut } = useConvexAuth();

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

Using Additional Better Auth Plugins

useAuthClient() returns the module-managed Better Auth client used by better-convex-nuxt. It is ideal for standard sign-in/sign-up flows, but it is not plugin-typed for arbitrary Better Auth plugins (for example admin, organization, etc.).

If you need plugin-specific client methods like authClient.admin.listUsers(), create your own client instance on the frontend and include:

  • convexClient() to preserve Convex token sync
  • the Better Auth client plugin (for example adminClient())

Server: install the Better Auth plugin in convex/auth.ts

convex/auth.ts
import { convex } from "@convex-dev/better-auth/plugins";
import { admin } from "better-auth/plugins";

plugins: [
  convex({ authConfig }),
  admin(),
];

Client: create a custom auth client for plugin methods

app/composables/useExtendedAuthClient.ts
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/vue";
import { adminClient } from "better-auth/client/plugins";

export function useExtendedAuthClient() {
  const authBaseURL = `${window.location.origin}/api/auth`;

  return createAuthClient({
    // If you use the Nuxt auth proxy, keep this same-origin.
    // Better Auth client expects an absolute URL.
    baseURL: authBaseURL,
    plugins: [convexClient(), adminClient()],
    fetchOptions: { credentials: "include" },
  });
}

Example: use plugin APIs + keep reactive logout

<script setup lang="ts">
const authClient = useExtendedAuthClient();
const { signOut } = useConvexAuth();

async function loadUsers() {
  const result = await authClient.admin.listUsers({
    query: { limit: 10 },
  });
  console.log(result);
}

async function handleSignOut() {
  // Prefer Convex-aware logout so local auth state updates immediately.
  await signOut();
  navigateTo("/");
}
</script>

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 { isAuthenticated, user, signOut } = useConvexAuth();

async function handleSignOut() {
  await 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>

Route Middleware (Auth and Permissions)

Use useConvexAuth() for simple "signed in / signed out" guards in route middleware.

app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
  const { isAuthenticated, isPending } = useConvexAuth()

  if (isPending.value) return
  if (!isAuthenticated.value) {
    return navigateTo('/auth/signin')
  }
})

If you need to query Convex in route middleware (for role/permission checks), use useConvexQuery(..., { subscribe: false }).

app/middleware/admin-only.ts
import { api } from '~~/convex/_generated/api'

export default defineNuxtRouteMiddleware(async () => {
  const { data: context } = await useConvexQuery(
    api.auth.getPermissionContext,
    {},
    {
      server: true,
      subscribe: false,
    },
  )

  if (!context.value || context.value.role !== 'admin') {
    return navigateTo('/')
  }
})
Use fetchQuery() only in server-only contexts (API routes, Nitro server/middleware, webhooks). Nuxt route middleware also runs during client-side navigation, so fetchQuery() alone will not cover SPA navigation.

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;
}

Which User Fields Layer Should I Use?

There are three different places where "extra user fields" can exist in a Better Auth + Convex app:

LayerExampleUsed byHow to add fields
Better Auth core schemauser.additionalFields.roleauthClient.useSession(), plugin endpoints, Better Auth DB recordsBetter Auth additionalFields + inferAdditionalFields(...)
Convex JWT claimsuseConvexAuth().user.roleNuxt UI/auth state convenience fieldsconvex({ jwt.definePayload }) + ConvexUser augmentation
Your app's Convex tablesusers.role, users.organizationIdAuthoritative permissions/business dataConvex schema + triggers/queries/mutations

Use the right layer for the job:

  • Need typed fields in Better Auth session/plugin responses: use Better Auth additionalFields.
  • Need fields on useConvexAuth().user: add JWT claims and extend ConvexUser.
  • Need authoritative roles/org membership: store/query them in Convex tables.

Better Auth Additional Fields for Sessions and Plugins

For Better Auth fields like user.additionalFields / session.additionalFields, use Better Auth's core schema config and client type inference.

See also: Better Auth: Extending Core Schema

Server: define additional fields and export an auth type alias

convex/auth.ts
export const createAuth = (ctx: GenericCtx<DataModel>) => {
  return betterAuth({
    // ...your existing config
    database: authComponent.adapter(ctx),
    user: {
      additionalFields: {
        organizationId: { type: "string", required: false },
        marketingOptIn: { type: "boolean", required: false },
      },
    },
  });
};

// Type-only bridge for frontend inference (safe to import with `import type`)
export type AppAuth = ReturnType<typeof createAuth>;

Client: infer additional fields on a custom auth client

app/composables/useExtendedAuthClient.ts
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/vue";
import { inferAdditionalFields, adminClient } from "better-auth/client/plugins";
import type { AppAuth } from "../../convex/auth";

export function useExtendedAuthClient() {
  const authBaseURL = `${window.location.origin}/api/auth`;

  return createAuthClient({
    // Better Auth client expects an absolute URL.
    baseURL: authBaseURL,
    plugins: [convexClient(), inferAdditionalFields<AppAuth>(), adminClient()],
    fetchOptions: { credentials: "include" },
  });
}
<script setup lang="ts">
const authClient = useExtendedAuthClient();
const session = authClient.useSession();

// Typed from Better Auth additionalFields (not from Convex JWT claims)
const orgId = computed(() => session.value.data?.user.organizationId);
const marketingOptIn = computed(() => session.value.data?.user.marketingOptIn);
</script>
Use import type { AppAuth } only. Do not runtime-import your server auth instance into client code.

Extending ConvexUser (Custom JWT Claims)

useConvexAuth().user is typed as ConvexUser. You can extend it with TypeScript module augmentation so app-specific fields like role, authId, or organizationId are recognized by your editor and type checker.

app/types/convex-user.d.ts
declare module "better-convex-nuxt/dist/runtime/utils/types" {
  interface ConvexUser {
    role?: "owner" | "admin" | "member" | "viewer";
    authId?: string;
    organizationId?: string;
  }
}

export {};

Runtime values require JWT claims

Type augmentation only changes TypeScript. For user.role (or any custom field) to exist at runtime, that field must be included in the Convex JWT payload.

convex/auth.ts
import { convex } from "@convex-dev/better-auth/plugins";

plugins: [
  convex({
    authConfig,
    jwt: {
      definePayload: ({ user }) => ({
        // Keep standard fields used by useConvexAuth()
        name: user.name,
        email: user.email,
        emailVerified: user.emailVerified,
        image: user.image ?? undefined,

        // Custom claims
        authId: user.id,
        role: "member", // example only
      }),
    },
  }),
];

Using extended fields in components

<script setup lang="ts">
const { user, refreshAuth } = useConvexAuth();

const role = computed(() => user.value?.role);

async function refreshClaims() {
  await refreshAuth();
}
</script>

<template>
  <p>JWT role claim: {{ role || "(no claim)" }}</p>
  <button @click="refreshClaims">Refresh Auth Claims</button>
</template>

Best Practices for Custom Claims

  • Treat useConvexAuth().user as identity + convenience claims (good for UI display and lightweight client checks).
  • Treat a Convex query (for example usePermissions() / api.auth.getPermissionContext) as the authoritative source for roles and org membership.
  • Keep JWT payload generation cheap and deterministic. Avoid expensive work in token minting.
  • Avoid calling Convex functions from jwt.definePayload() to build claims. Prefer data already available on the auth user/session, or fetch authoritative role/org data separately via Convex queries.
  • If claims can change during a session (e.g. role changes), call useConvexAuth().refreshAuth() after the change to fetch a fresh token.
  • Never rely on frontend/JWT claims alone for backend authorization. Always enforce permissions inside Convex functions.

useAuthClient Returns

ReturnTypeDescription
authClientAuthClient | nullBetter Auth client instance

useAuthClient() returns the module-managed base client. For plugin-specific typed methods (for example adminClient() APIs or inferAdditionalFields(...) typing), create a custom client composable as shown above.


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.