---
title: "Authentication"
description: "Sign in from Nuxt, read reactive session state, protect routes, and gate UI with the auth components."
canonical_url: "https://nuxt-backend.dev/docs/auth/sign-in"
---
# Authentication

> Sign in from Nuxt, read reactive session state, protect routes, and gate UI with the auth components.

Better Auth is wired in by the module. App code talks to it through a single composable — `useAuth()` — plus an opt-in route middleware and four auth-state components.

## `useAuth()`

`useAuth()` is the single Better Auth service. It exposes the Better Auth client, the reactive session wrapper, the computed auth state used by the Convex integration, and the passwordless calls the packaged pages use:

```vue
<script setup lang="ts">
const { client, session, isAuthenticated, isLoading, sendOtp, signInWithOtp, signOut } = useAuth()

await sendOtp('user@example.com')                              // emails a one-time code
await signInWithOtp({ email: 'user@example.com', otp: '123456' }) // signs in, or signs up on first use
const user = computed(() => session.value.data?.user)
</script>
```

Sign-in is passwordless by default: an emailed one-time code, with an optional passkey saved afterwards. Better Auth client calls **resolve** `{ data, error }` instead of throwing — check `error`, or wrap the call in `unwrapAuth()` (auto-imported) to turn it into an exception.

<callout icon="i-lucide-key-round">

Email + password is off (`emailAndPassword.enabled: false`). To offer it as well, pass `authOptions: { emailAndPassword: { enabled: true } }` to `setupAuth` in `backend/auth.ts` ([customizing auth](https://nuxt-backend.dev/docs/auth/setup#auth-route-and-options)) — after which `client.signIn.email({ email, password })` and `client.signUp.email(...)` work as in the Better Auth docs.

</callout>

## Signing in

The shipped `/login` page and the `<AuthForm>` component are built on `useLoginFlow()`. For your own page, use the same composable — it owns the step state (`choose` → `request-code` → `verify-code` → `add-passkey`), validation, the pending flag and the error message:

```vue [pages/login.vue]
<script setup lang="ts">
const { step, email, name, otp, pending, error, sendCode, verifyCode, addPasskey, skipPasskey, signInWithPasskey, goTo } = useLoginFlow({
  onSuccess: () => navigateTo('/account'),
})
</script>

<template>
  <form v-if="step === 'choose' || step === 'request-code'" @submit.prevent="sendCode">
    <input v-model="name" type="text" placeholder="Name (for new accounts)" autocomplete="name">
    <input v-model="email" type="email" placeholder="Email" autocomplete="email">
    <button :disabled="pending" type="submit">Email me a code</button>
    <button type="button" @click="signInWithPasskey">Use a passkey</button>
  </form>

  <form v-else-if="step === 'verify-code'" @submit.prevent="verifyCode">
    <input v-model="otp" inputmode="numeric" autocomplete="one-time-code" placeholder="6-digit code">
    <button :disabled="pending" type="submit">Sign in</button>
    <button type="button" @click="goTo('request-code')">Use another email</button>
  </form>

  <div v-else-if="step === 'add-passkey'">
    <p>Signed in. Save a passkey for next time?</p>
    <button :disabled="pending" type="button" @click="addPasskey">Save a passkey</button>
    <button type="button" @click="skipPasskey">Not now</button>
  </div>

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

`useLoginFlow(options?)` takes:

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        requireName
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
    </td>
    
    <td>
      UI hint read by <code>
        <AuthForm>
      </code>
      
      : show a name field on the email step. <code>
        verifyCode()
      </code>
      
       sends <code>
        name
      </code>
      
       with the code whenever it is non-empty — on a custom page, keep it bound through the verify step, not only the request step.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        validateEmail
      </code>
    </td>
    
    <td>
      a basic shape check
    </td>
    
    <td>
      <code>
        (email) => boolean | string
      </code>
      
       — gates the email before <code>
        sendCode
      </code>
      
      . Return <code>
        false
      </code>
      
       for the default message, or a string for a custom one.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onSuccess
      </code>
    </td>
    
    <td>
      —
    </td>
    
    <td>
      Called once the user is signed in and the flow is complete (e.g. <code>
        navigateTo
      </code>
      
      ).
    </td>
  </tr>
</tbody>
</table>

It returns:

<table>
<thead>
  <tr>
    <th>
      Name
    </th>
    
    <th>
      Type / effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        step
      </code>
    </td>
    
    <td>
      <code>
        Ref<'choose' | 'request-code' | 'verify-code' | 'add-passkey'>
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        email
      </code>
      
      , <code>
        name
      </code>
      
      , <code>
        otp
      </code>
    </td>
    
    <td>
      <code>
        Ref<string>
      </code>
      
       — bind them to your inputs
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pending
      </code>
    </td>
    
    <td>
      <code>
        Ref<boolean>
      </code>
      
       — an action is in flight
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        error
      </code>
    </td>
    
    <td>
      <code>
        Ref<string | null>
      </code>
      
       — the last failed action's message
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retryAfter
      </code>
    </td>
    
    <td>
      <code>
        Ref<number | null>
      </code>
      
       — after a rate-limit refusal, the seconds to wait (rounded up); <code>
        null
      </code>
      
       otherwise
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        emailValid
      </code>
    </td>
    
    <td>
      <code>
        ComputedRef<boolean>
      </code>
      
       — whether <code>
        email
      </code>
      
       passes <code>
        validateEmail
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        signInWithPasskey()
      </code>
    </td>
    
    <td>
      sign in with a passkey already saved on this device
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sendCode()
      </code>
    </td>
    
    <td>
      email a code to <code>
        email
      </code>
      
       → <code>
        verify-code
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        verifyCode()
      </code>
    </td>
    
    <td>
      verify <code>
        otp
      </code>
      
       (signs in, or signs up on first use) → <code>
        add-passkey
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        addPasskey()
      </code>
    </td>
    
    <td>
      save a passkey on the new session → done
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        skipPasskey()
      </code>
    </td>
    
    <td>
      skip the passkey offer → done
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        goTo(step)
      </code>
    </td>
    
    <td>
      jump to a step, clearing <code>
        otp
      </code>
      
       and <code>
        error
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        reset()
      </code>
    </td>
    
    <td>
      back to <code>
        choose
      </code>
      
       with every field cleared
    </td>
  </tr>
</tbody>
</table>

A refused request (HTTP 429) leaves the flow on its current step. `error` reads *"Too many attempts. Try again in 30 seconds."* and `retryAfter` holds the `30`, so a custom page can count down or disable its button until then. When the wait is unknown, the message says *"…Try again in a moment."* and `retryAfter` stays `null`. See [rate limiting](https://nuxt-backend.dev/docs/ship/production/rate-limits#what-the-user-sees) for which refusals carry a wait. Checking codes has a budget of its own: ten checks an hour per address (`emailOtpAttempt`), however many codes were sent.

A refusal from your `canSignIn` gate (HTTP 403) reaches whoever typed the address, before any code is sent. If the gate refuses new addresses, that answer tells anyone which addresses have an account: see the [note on refusing at sign-in](https://nuxt-backend.dev/docs/auth/setup#cross-component-wiring) for the way around it.

Every step is also available one level down on `useAuth()` — `sendOtp`, `signInWithOtp`, `signInWithPasskey`, `registerPasskey` — when a page needs a shape the composable does not model.

## Reading session state

```vue [pages/account.vue]
<script setup lang="ts">
import { api } from '#backend/api'

definePageMeta({ middleware: 'auth' })

const { session } = useAuth()
const currentUser = useQuery(api.auth.getAuthUser, {})
const email = computed(() => session.value.data?.user.email)
</script>

<template>
  <section>
    <p>Signed in as {{ email }}</p>
    <pre>{{ currentUser }}</pre>
  </section>
</template>
```

`useAuth().session` exposes the Better Auth session, and `useQuery(...)` keeps Convex data live after hydration.

## Protecting routes

Guard a page with the built-in `auth` middleware — it's registered **non-global**, so installing the module never guards routes by surprise:

```vue
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
</script>
```

Unauthenticated users are redirected to the sign-in page (`/login`, or wherever [`pages.login`](https://nuxt-backend.dev/docs/reference/configuration/module-options) puts it) with the original destination in `?redirect=`. The guard runs on the server for direct loads and in the browser for client-side navigation.

`auth` is this package's name for the guard `nuxt-convex-module` registers as `convex-auth`. The module registers the same middleware under `auth` only, so a page that still says `convex-auth` fails to resolve its middleware: rename it.

## Auth-state components

Four components render their slot based on auth state — useful for headers, menus, and loading skeletons:

```vue
<template>
  <Authenticated>Signed in — show the dashboard</Authenticated>
  <Unauthenticated>Show a sign-in button</Unauthenticated>
  <AuthLoading>Resolving auth…</AuthLoading>
  <AuthRefreshing>Reconnecting…</AuthRefreshing>
</template>
```

<table>
<thead>
  <tr>
    <th>
      Component
    </th>
    
    <th>
      Renders its slot when
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        <Authenticated>
      </code>
    </td>
    
    <td>
      the user is authenticated
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <Unauthenticated>
      </code>
    </td>
    
    <td>
      the user is unauthenticated
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <AuthLoading>
      </code>
    </td>
    
    <td>
      auth state is still resolving
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        <AuthRefreshing>
      </code>
    </td>
    
    <td>
      an authenticated session's token is being refreshed after the backend rejected it (routine background rotation does not trigger it)
    </td>
  </tr>
</tbody>
</table>

## Workspace invitations

A member invites by email, and the invitee accepts on the built-in `/accept-invitation` page after signing in. [Invitations end-to-end](https://nuxt-backend.dev/docs/auth/workspaces#invitations-end-to-end) has the flow, the `useOrganization()` calls on both sides and how to customize the email, the link and the page.

## Advanced: low-level auth helpers

`useAuthState()` and the components above read the auth state the module provides. They are building blocks for your own UI and are **not required** for the default Better Auth setup scaffolded by the module.

```ts
function useAuthState(): {
  isLoading: ComputedRef<boolean>
  isAuthenticated: ComputedRef<boolean>
  isRefreshing: ComputedRef<boolean>
}
```

A custom auth integration provides that state itself with `provideConvexAuth`, imported from `nuxt-convex-module/client`: the module does not auto-import it.

The three flags are computed refs — read `.value` in script (templates unwrap them). `isRefreshing` is the state `<AuthRefreshing>` renders on: the backend rejected a confirmed token and a new one is being fetched.

<tip to="https://nuxt-backend.dev/docs/auth/setup">

To customize Better Auth itself — add providers, plugins, or change the auth route — see the Convex Backend section.

</tip>

<page-keys>

- Try it live in the playground: [/playground/platform/account](https://nuxt-backend.dev/playground/platform/account)
- API reference: [/docs/reference/api/use-login-flow](https://nuxt-backend.dev/docs/reference/api/use-login-flow)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-login-flow.ts](https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-login-flow.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
