---
name: netlify-identity
description: Use whenever the task involves authentication, signups, logins, password recovery, user accounts, role-based access control, OAuth providers, protected routes, protecting Netlify Functions, server-side auth in SSR frameworks, or Netlify Identity. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `gotrue-js` as they are deprecated. Make sure to always run `node scripts/enable.cjs` located in this skill's directory after implementing Netlify Identity.
---

# Netlify Identity

Netlify Identity is a user management service for signups, logins, password recovery, user metadata, and role-based
access control. It is built on [GoTrue](https://github.com/netlify/gotrue) and issues JSON Web Tokens (JWTs).

## Enable Feature

After implementing Netlify Identity, you **must** run the enable script to activate the feature on the deploy. The script
is located in this skill's `scripts/` directory:

```bash
node scripts/enable.cjs
```

**NEVER use `netlify-identity-widget` or `gotrue-js`. Always use `@netlify/identity`.** It provides a unified, headless
TypeScript API that works in both browser and server contexts (Netlify Functions, Edge Functions, SSR frameworks). It
replaces all previous Identity client libraries.

## Setup

```bash
npm install @netlify/identity
```

Identity is enabled on the site when you run the enable script above. Default settings:

- **Registration** - Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.
- **Autoconfirm** - Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip
  confirmation during development.

For local development, use `netlify dev` so the Identity endpoint is available.

## Quick Start

Log in from the browser:

```typescript
import { login, getUser } from '@netlify/identity'

const user = await login('jane@example.com', 'password123')
console.log(`Hello, ${user.name}`)

// Later, check auth state
const currentUser = await getUser()
```

Protect a Netlify Function:

```typescript
// netlify/functions/protected.mts
import { getUser } from '@netlify/identity'
import type { Context } from '@netlify/functions'

export default async (req: Request, context: Context) => {
  const user = await getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })
  return Response.json({ id: user.id, email: user.email })
}
```

## Error Handling

`@netlify/identity` throws two error classes:

- **`AuthError`** - Thrown by auth operations (login, signup, logout, etc.). Has `message`, optional `status` (HTTP
  status code from GoTrue), and optional `cause` (original error).
- **`MissingIdentityError`** - Thrown when Identity is not configured in the current environment (site doesn't have
  Identity enabled, or not running via `netlify dev`).

`getUser()` and `isAuthenticated()` never throw - they return `null` and `false` respectively on failure.

### Try/Catch Pattern

```typescript
import { login, AuthError, MissingIdentityError } from '@netlify/identity'

try {
  const user = await login(email, password)
} catch (error) {
  if (error instanceof MissingIdentityError) {
    // Identity not configured - show setup instructions
    showError('Identity is not enabled on this site.')
    return
  }
  if (error instanceof AuthError) {
    switch (error.status) {
      case 401:
        showError('Invalid email or password.')
        break
      case 403:
        showError('Signups are not allowed for this site.')
        break
      case 422:
        showError('Invalid input. Check your email and password.')
        break
      case 404:
        showError('User not found.')
        break
      default:
        showError(error.message)
    }
    return
  }
  throw error
}
```

### Common Status Codes

| Status | Meaning |
|--------|---------|
| 401 | Invalid credentials or expired token |
| 403 | Action not allowed (e.g., signups disabled) |
| 422 | Validation error (e.g., weak password, malformed email) |
| 404 | User or resource not found |

## Authentication Flows

### Login

```typescript
import { login, AuthError } from '@netlify/identity'

let loading = false

async function handleLogin(email: string, password: string) {
  loading = true
  try {
    const user = await login(email, password)
    showSuccess(`Welcome back, ${user.name ?? user.email}`)
  } catch (error) {
    if (error instanceof AuthError) {
      showError(error.status === 401 ? 'Invalid email or password.' : error.message)
    }
  } finally {
    loading = false
  }
}
```

### Signup

After signup, check `user.emailVerified` to determine if the user was auto-confirmed (logged in immediately) or needs
to confirm their email first.

```typescript
import { signup, AuthError } from '@netlify/identity'

async function handleSignup(email: string, password: string, name: string) {
  try {
    const user = await signup(email, password, { full_name: name })
    if (user.emailVerified) {
      // Autoconfirm is ON - user is logged in
      showSuccess('Account created. You are now logged in.')
    } else {
      // Autoconfirm is OFF - confirmation email sent, user is NOT logged in
      showSuccess('Check your email to confirm your account.')
    }
  } catch (error) {
    if (error instanceof AuthError) {
      showError(error.status === 403 ? 'Signups are not allowed.' : error.message)
    }
  }
}
```

When autoconfirm is off, the confirmation email contains a link that redirects the user back to the site with
`#confirmation_token=<token>` in the URL hash. `handleAuthCallback()` processes this automatically - it calls
`confirmEmail()` under the hood, logs the user in, and returns `{ type: 'confirmation', user }`. This is why
`handleAuthCallback()` must be called on page load (see the OAuth section below for the full switch).

### OAuth

OAuth is a two-step flow: `oauthLogin(provider)` redirects away from the site, then `handleAuthCallback()` processes
the redirect when the user returns.

```typescript
import { oauthLogin } from '@netlify/identity'

// Step 1: Redirect to OAuth provider (this navigates away - never returns)
function handleOAuthClick(provider: 'google' | 'github' | 'gitlab' | 'bitbucket') {
  oauthLogin(provider)
}
```

```typescript
import { handleAuthCallback, AuthError } from '@netlify/identity'

// Step 2: Process the redirect on page load
async function processCallback() {
  try {
    const result = await handleAuthCallback()
    if (!result) return // No callback hash present - normal page load

    switch (result.type) {
      case 'oauth':
        showSuccess(`Logged in as ${result.user?.email}`)
        break
      case 'confirmation':
        showSuccess('Email confirmed. You are now logged in.')
        break
      case 'recovery':
        // User is authenticated but must set a new password
        showPasswordResetForm(result.user)
        break
      case 'invite':
        // User must set a password to accept the invite
        showInviteAcceptForm(result.token)
        break
      case 'email_change':
        showSuccess('Email address updated.')
        break
    }
  } catch (error) {
    if (error instanceof AuthError) {
      showError(error.message)
    }
  }
}
```

Always call `handleAuthCallback()` on page load in any app that uses OAuth, password recovery, invites, or email
confirmation. It handles all callback types via the URL hash.

### Password Recovery

Three-step flow: request recovery email, handle the callback, then set a new password.

```typescript
import { requestPasswordRecovery, handleAuthCallback, updateUser, AuthError } from '@netlify/identity'

// Step 1: Send recovery email
async function handleForgotPassword(email: string) {
  try {
    await requestPasswordRecovery(email)
    showSuccess('Check your email for a password reset link.')
  } catch (error) {
    if (error instanceof AuthError) showError(error.message)
  }
}

// Step 2: handleAuthCallback() returns { type: 'recovery', user } - show password reset form
// (See the handleAuthCallback switch above)

// Step 3: Set new password
async function handlePasswordReset(newPassword: string) {
  try {
    await updateUser({ password: newPassword })
    showSuccess('Password updated.')
  } catch (error) {
    if (error instanceof AuthError) showError(error.message)
  }
}
```

Note: The recovery callback fires a `'recovery'` auth event, not `'login'`. The user is authenticated but should be
prompted to set a new password before navigating away.

### Invite Acceptance

When a user clicks an invite link, `handleAuthCallback()` returns `{ type: 'invite', user: null, token }`. Use the
token to accept the invite and set a password.

```typescript
import { acceptInvite, AuthError } from '@netlify/identity'

async function handleAcceptInvite(token: string, password: string) {
  try {
    const user = await acceptInvite(token, password)
    showSuccess(`Welcome, ${user.email}! Your account is ready.`)
  } catch (error) {
    if (error instanceof AuthError) showError(error.message)
  }
}
```

### Email Change

When a user verifies an email change, `handleAuthCallback()` returns `{ type: 'email_change', user }`. This requires an
active browser session - the user must be logged in when clicking the verification link.

```typescript
import { verifyEmailChange, AuthError } from '@netlify/identity'

// If you need to verify programmatically with a token:
async function handleEmailChangeVerification(token: string) {
  try {
    const user = await verifyEmailChange(token)
    showSuccess(`Email updated to ${user.email}`)
  } catch (error) {
    if (error instanceof AuthError) showError(error.message)
  }
}
```

### Session Hydration

`hydrateSession()` bridges server-set cookies to the browser session. Call it on page load when using server-side login
(e.g., login inside a Netlify Function followed by a redirect).

```typescript
import { hydrateSession } from '@netlify/identity'

// On page load
const user = await hydrateSession()
if (user) {
  // Browser session is now in sync with server-set cookies
}
```

Note: `getUser()` auto-hydrates from the `nf_jwt` cookie if no browser session exists, so explicit `hydrateSession()`
is only needed when you want to restore the full session (including token refresh timers) after a server-side login.

## Auth Events

Subscribe to auth state changes with `onAuthChange`. Returns an unsubscribe function. No-op on server.

```typescript
import { onAuthChange, AUTH_EVENTS } from '@netlify/identity'

const unsubscribe = onAuthChange((event, user) => {
  switch (event) {
    case AUTH_EVENTS.LOGIN:
      console.log('User logged in:', user?.email)
      break
    case AUTH_EVENTS.LOGOUT:
      console.log('User logged out')
      break
    case AUTH_EVENTS.TOKEN_REFRESH:
      // Token auto-refreshed in background
      break
    case AUTH_EVENTS.USER_UPDATED:
      console.log('User profile updated:', user?.email)
      break
    case AUTH_EVENTS.RECOVERY:
      // Recovery token processed - prompt for new password
      console.log('Password recovery initiated')
      break
  }
})

// Later: unsubscribe()
```

Auth events are automatically detected across browser tabs via the storage event listener - no extra setup needed.

## Settings-Driven UI

Fetch the project's Identity settings to conditionally render signup forms and OAuth buttons.

```typescript
import { getSettings } from '@netlify/identity'

const settings = await getSettings()
// settings.autoconfirm - boolean, whether email confirmation is skipped
// settings.disableSignup - boolean, whether registration is closed
// settings.providers - Record<AuthProvider, boolean>, e.g. { google: true, github: true, ... }

// Conditionally render signup
if (!settings.disableSignup) {
  showSignupForm()
}

// Conditionally render OAuth buttons
for (const [provider, enabled] of Object.entries(settings.providers)) {
  if (enabled) {
    showOAuthButton(provider)
  }
}
```

## Full API Reference

For the complete API reference - all function signatures, type definitions, OAuth helpers, admin operations, session
management, auth events, and framework-specific integration examples - read the package README:

```
node_modules/@netlify/identity/README.md
```

The README is shipped with the npm package and is always in sync with the installed version.

## SSR Integration Patterns

For SSR frameworks, the recommended pattern is:

- **Browser-side** for auth mutations: `login()`, `signup()`, `logout()`, `oauthLogin()`
- **Server-side** for reading auth state: `getUser()`, `getSettings()`, `getIdentityConfig()`

Browser-side auth mutations set the `nf_jwt` cookie and localStorage, and emit `onAuthChange` events. The
server reads the cookie on the next request.

The library also supports server-side mutations (`login()`, `signup()`, `logout()` inside Netlify Functions), but these
require the Netlify Functions runtime to set cookies. After a server-side mutation, use a full page navigation so the
browser sends the new cookie.

Always use `window.location.href` (not framework router navigation) after server-side auth mutations in Next.js,
TanStack Start, and SvelteKit. Remix `redirect()` is safe because Remix actions return real HTTP responses.

## Identity Event Functions

Special serverless functions that trigger on Identity lifecycle events. These use the **legacy named `handler` export**
(not the modern default export) because they receive `event.body` containing the user payload.

Always use the legacy named `handler` export (not default export) for Identity event functions. The filename must match
the event name exactly (e.g., `netlify/functions/identity-signup.mts`).

**Event names:** `identity-validate`, `identity-signup`, `identity-login`

- `identity-signup` - fires when a new user signs up (email/password or OAuth)
- `identity-login` - fires on each login
- `identity-validate` - fires during signup before the user is created; return a non-200 status to reject

### Example: Assign Default Role on Signup

```typescript
// netlify/functions/identity-signup.mts
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions'

const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
  const { user } = JSON.parse(event.body || '{}')

  return {
    statusCode: 200,
    body: JSON.stringify({
      app_metadata: {
        ...user.app_metadata,
        roles: ['member'],
      },
    }),
  }
}

export { handler }
```

The response body replaces `app_metadata` and/or `user_metadata` on the user record - include all fields you want to
keep, not just new ones.

For bulk user management or role changes outside lifecycle events, use the `admin` API instead of Identity event
functions.

## Roles and Authorization

### First Admin User

The first admin user cannot be created through code alone. You must direct the user to set it up through the Netlify UI:

1. Go to **Identity** in the project sidebar in the Netlify dashboard
2. Click **Invite users** and enter the admin user's email address
3. After the user accepts the invite, click the user in the Identity list to open their detail page
4. In the **Roles** field, add the `admin` role and save

Once the first admin exists, subsequent users can be managed programmatically using Identity event functions (e.g., assigning roles in `identity-signup`) or role-based redirects.

### Metadata Types

- **`app_metadata.roles`** - Server-controlled. Only settable via the Netlify UI, admin API, or Identity event functions.
  Do not allow users to set their own roles.
- **`user_metadata`** - User-controlled. Users can update this via `updateUser({ data: { ... } })`.

### Role-Based Redirects

Use `netlify.toml` to restrict paths by role:

```toml
[[redirects]]
  from = "/admin/*"
  to = "/admin/:splat"
  status = 200
  conditions = { Role = ["admin"] }

[[redirects]]
  from = "/admin/*"
  to = "/"
  status = 302
```

Rules are evaluated top-to-bottom. The first redirect matches users with the `admin` role; everyone else falls through
to the second rule and is redirected away.

**How it works:** The `nf_jwt` cookie is read by the CDN to evaluate `conditions = { Role = [...] }`. Without this
cookie, role-based redirects will not work.

## Common Errors & Solutions

### "Signups not allowed for this instance" (403)

**Cause:** Registration is set to Invite only.

**Fix:**

1. Change to Open in **Project configuration > Identity**
2. Or invite users from the Identity tab in the Netlify UI

```typescript
if (error instanceof AuthError && error.status === 403) {
  showError('Signups are disabled. Contact the site admin for an invite.')
}
```

### Invalid credentials (401)

**Cause:** Wrong email or password on login.

**Fix:** Show a user-facing error and let the user retry. Do not reveal whether the email or password was wrong.

```typescript
if (error instanceof AuthError && error.status === 401) {
  showError('Invalid email or password.')
}
```

### "Email not confirmed"

**Cause:** User tries to log in before confirming their email (autoconfirm is off).

**Fix:** Tell the user to check their inbox. Optionally provide a way to resend the confirmation email via `signup()`
with the same credentials.

### "Token expired" / 401 on API calls

**Cause:** Stale access token.

**Fix:**

1. Always use `getUser()` before authenticated requests - it auto-hydrates from cookies
2. In the browser, the library auto-refreshes tokens via `startTokenRefresh()`
3. On the server, call `refreshSession()` in middleware to handle near-expiry tokens

```typescript
const newToken = await refreshSession()
if (newToken) {
  // Token was refreshed - retry the request
}
```

### Identity event function not triggering

**Cause:** Filename or export format does not match expected convention.

**Fix:**

1. Verify filename matches exactly: `identity-signup`, `identity-validate`, or `identity-login`
2. Place in `netlify/functions/` with `.mts` or `.mjs` extension
3. Use named `handler` export (not default export)

### `MissingIdentityError`

**Cause:** Identity is not configured in the current environment.

**Fix:**

1. Ensure Identity is enabled on the project
2. Use `netlify dev` for local development so the Identity endpoint is available

```typescript
if (error instanceof MissingIdentityError) {
  showError('Identity is not enabled. Run "netlify dev" or enable Identity in project settings.')
}
```

### `AuthError` on server - missing Netlify runtime

**Cause:** Server-side `login()`, `signup()`, or `logout()` require the Netlify Functions runtime to set cookies.

**Fix:**

1. Deploy to Netlify to use server-side auth mutations
2. Or use `netlify dev` for local development

### "User not found" after OAuth login

**Cause:** OAuth provider is not enabled for the project.

**Fix:**

1. Enable the provider in **Project configuration > Identity > External providers**
2. Users are created automatically on first OAuth login

### Account operations fail after server-side login

**Cause:** Browser-side session is not bootstrapped from server-set cookies.

**Fix:** Call `hydrateSession()` on page load to bridge server-set cookies to the browser session. Then use
`updateUser()`, `verifyEmailChange()`, or other account operations.

```typescript
import { hydrateSession, updateUser } from '@netlify/identity'

// On page load after server-side login
await hydrateSession()

// Now account operations work
await updateUser({ data: { full_name: 'Jane' } })
```

### "Email change verification requires an active browser session"

**Cause:** `verifyEmailChange()` was called without an active session. The user must be logged in when clicking the
email change verification link.

**Fix:** Ensure the user is logged in before processing the `email_change` callback. If the session expired, prompt
the user to log in first.

### "No user is currently logged in"

**Cause:** An account operation (`updateUser`, `verifyEmailChange`) was called without an authenticated user.

**Fix:** Check `getUser()` before calling account operations. If `null`, redirect to login.

```typescript
const user = await getUser()
if (!user) {
  redirectToLogin()
  return
}
await updateUser({ data: { full_name: 'Jane' } })
```

### Stale session - user deleted server-side

**Cause:** `getUser()` returns `null` when the `nf_jwt` cookie is gone but localStorage still has a stale
session. This happens when a user is deleted via the admin API or Netlify UI.

**Fix:** `getUser()` handles this gracefully - it returns `null` and the stale localStorage entry is ignored. Always
check for `null` before using the user object.
