← NetlifyCONTENT HISTORY

Update to Netlify

Snapshot Oct 7, 2026 · 00:02 UTC · version 1.6.0

Collection source: downloaded plugin package. These snapshots do not have a confirmed matching collection source. Differences in file lists alone do not establish changes to the package.

WHAT CHANGED · RULE-BASED ANALYSIS

Instructions updated for netlify-identity

Instruction wording changed from “Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `g...” to “Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task invol...”. 151 additional added or edited lines are in the evidence.

Observed in instructions or declared skills. Runtime behavior has not been tested.

Product description

Before

Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `g...

After

Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task invol...

Skill instructions

Before

Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `g...

After

Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task invol...

Supporting files

Before

[{"relative_path":"LICENSE.txt","size_in_bytes":10776},{"relative_path":"agents/openai.yaml","size_in_bytes":335},{"relative_path":"assets/netlify-small.svg","size_in_bytes":1291},{"relative_path":"assets/netlify.png","size_in_bytes":268...

After

[{"relative_path":"references/advanced-patterns.md","size_in_bytes":4428},{"relative_path":"references/authorization-and-sessions.md","size_in_bytes":3384}]

Compare saved observations

Download comparison JSON
Full technical diff · 3 changed fields

changed /description

BEFORE
"Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `gotrue-js` — they are deprecated."
AFTER
"Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, \"auth middleware\" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead."

changed /included_files

BEFORE
[
  {
    "relative_path": "LICENSE.txt",
    "size_in_bytes": 10776
  },
  {
    "relative_path": "agents/openai.yaml",
    "size_in_bytes": 335
  },
  {
    "relative_path": "assets/netlify-small.svg",
    "size_in_bytes": 1291
  },
  {
    "relative_path": "assets/netlify.png",
    "size_in_bytes": 2686
  },
  {
    "relative_path": "references/advanced-patterns.md",
    "size_in_bytes": 4095
  }
]
AFTER
[
  {
    "relative_path": "references/advanced-patterns.md",
    "size_in_bytes": 4428
  },
  {
    "relative_path": "references/authorization-and-sessions.md",
    "size_in_bytes": 3384
  }
]

changed /skill_md_contents

BEFORE
"---\nname: netlify-identity\ndescription: Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `gotrue-js` — they are deprecated.\n---\n\n# Netlify Identity\n\nNetlify 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).\n\n**Always use `@netlify/identity`.** Never use `netlify-identity-widget` or `gotrue-js` — they are deprecated. `@netlify/identity` provides a unified, headless TypeScript API that works in both browser and server contexts (Netlify Functions, Edge Functions, SSR frameworks).\n\n## Setup\n\n```bash\nnpm install @netlify/identity\n```\n\nIdentity is automatically enabled when a deploy created by a Netlify Agent Runner session includes Identity code. Otherwise, it must be manually enabled in the UI. These are the default settings:\n\n- **Registration** — Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.\n- **Autoconfirm** — Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip confirmation during development.\n\n### Local Development\n\nIdentity does **not** currently work with `netlify dev`. You must deploy to Netlify to test Identity features. Use `npx netlify deploy` for preview deploys during development. This limitation may be resolved in a future release.\n\n## Quick Start\n\nLog in from the browser:\n\n```typescript\nimport { login, getUser } from '@netlify/identity'\n\nconst user = await login('user@example.com', '<password>')\nconsole.log(`Hello, ${user.name}`)\n\n// Later, check auth state\nconst currentUser = await getUser()\n```\n\nProtect a Netlify Function:\n\n```typescript\n// netlify/functions/protected.mts\nimport { getUser } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  const user = await getUser()\n  if (!user) return new Response('Unauthorized', { status: 401 })\n  return Response.json({ id: user.id, email: user.email })\n}\n```\n\n## Core API\n\nImport and use headless functions directly:\n\n```typescript\nimport {\n  getUser,\n  handleAuthCallback,\n  login,\n  logout,\n  signup,\n  oauthLogin,\n  onAuthChange,\n  getSettings,\n} from '@netlify/identity'\n```\n\n### Login\n\n```typescript\nimport { login, AuthError } from '@netlify/identity'\n\nasync function handleLogin(email: string, password: string) {\n  try {\n    const user = await login(email, password)\n    showSuccess(`Welcome back, ${user.name ?? user.email}`)\n  } catch (error) {\n    if (error instanceof AuthError) {\n      showError(error.status === 401 ? 'Invalid email or password.' : error.message)\n    }\n  }\n}\n```\n\n### Signup\n\nAfter signup, check `user.emailVerified` to determine if the user was auto-confirmed or needs to confirm their email.\n\n```typescript\nimport { signup, AuthError } from '@netlify/identity'\n\nasync function handleSignup(email: string, password: string, name: string) {\n  try {\n    const user = await signup(email, password, { full_name: name })\n    if (user.emailVerified) {\n      // Autoconfirm ON — user is logged in immediately\n      showSuccess('Account created. You are now logged in.')\n    } else {\n      // Autoconfirm OFF — confirmation email sent\n      showSuccess('Check your email to confirm your account.')\n    }\n  } catch (error) {\n    if (error instanceof AuthError) {\n      showError(error.status === 403 ? 'Signups are not allowed.' : error.message)\n    }\n  }\n}\n```\n\n### Logout\n\n```typescript\nimport { logout } from '@netlify/identity'\n\nawait logout()\n```\n\n### OAuth\n\nOAuth is a two-step flow: `oauthLogin(provider)` redirects away from the site, then `handleAuthCallback()` processes the redirect when the user returns.\n\n```typescript\nimport { oauthLogin } from '@netlify/identity'\n\n// Step 1: Redirect to provider (navigates away — never returns)\nfunction handleOAuthClick(provider: 'google' | 'github' | 'gitlab' | 'bitbucket') {\n  oauthLogin(provider)\n}\n```\n\nEnable providers in **Project configuration > Identity > External providers** before using OAuth.\n\n### Handling Callbacks\n\nAlways call `handleAuthCallback()` on page load in any app that uses OAuth, password recovery, invites, or email confirmation. It processes all callback types via the URL hash.\n\n```typescript\nimport { handleAuthCallback, AuthError } from '@netlify/identity'\n\nasync function processCallback() {\n  try {\n    const result = await handleAuthCallback()\n    if (!result) return // No callback hash — normal page load\n\n    switch (result.type) {\n      case 'oauth':\n        showSuccess(`Logged in as ${result.user?.email}`)\n        break\n      case 'confirmation':\n        showSuccess('Email confirmed. You are now logged in.')\n        break\n      case 'recovery':\n        // User is authenticated but must set a new password\n        showPasswordResetForm(result.user)\n        break\n      case 'invite':\n        // User must set a password to accept the invite\n        showInviteAcceptForm(result.token)\n        break\n      case 'email_change':\n        showSuccess('Email address updated.')\n        break\n    }\n  } catch (error) {\n    if (error instanceof AuthError) showError(error.message)\n  }\n}\n```\n\n### Auth State\n\n```typescript\nimport { getUser, onAuthChange, AUTH_EVENTS } from '@netlify/identity'\n\n// Check current user (never throws — returns null if not authenticated)\nconst user = await getUser()\n\n// Subscribe to auth state changes (returns unsubscribe function)\nconst unsubscribe = onAuthChange((event, user) => {\n  switch (event) {\n    case AUTH_EVENTS.LOGIN:\n      console.log('Logged in:', user?.email)\n      break\n    case AUTH_EVENTS.LOGOUT:\n      console.log('Logged out')\n      break\n    case AUTH_EVENTS.TOKEN_REFRESH:\n      break\n    case AUTH_EVENTS.USER_UPDATED:\n      console.log('Profile updated:', user?.email)\n      break\n    case AUTH_EVENTS.RECOVERY:\n      console.log('Password recovery initiated')\n      break\n  }\n})\n```\n\n### Settings-Driven UI\n\nFetch the project's Identity settings to conditionally render signup forms and OAuth buttons.\n\n```typescript\nimport { getSettings } from '@netlify/identity'\n\nconst settings = await getSettings()\n// settings.autoconfirm — boolean\n// settings.disableSignup — boolean\n// settings.providers — Record<AuthProvider, boolean>\n\nif (!settings.disableSignup) showSignupForm()\n\nfor (const [provider, enabled] of Object.entries(settings.providers)) {\n  if (enabled) showOAuthButton(provider)\n}\n```\n\n## Minimal React Example\n\n```tsx\nimport { useEffect, useState } from 'react'\nimport {\n  getUser,\n  handleAuthCallback,\n  login,\n  logout,\n  oauthLogin,\n  onAuthChange,\n} from '@netlify/identity'\n\nfunction App() {\n  const [user, setUser] = useState(null)\n  const [loading, setLoading] = useState(true)\n\n  useEffect(() => {\n    ;(async () => {\n      await handleAuthCallback()\n      setUser(await getUser())\n      setLoading(false)\n    })()\n    return onAuthChange((_event, currentUser) => setUser(currentUser))\n  }, [])\n\n  const handleLogin = async (email, password) => {\n    const currentUser = await login(email, password)\n    setUser(currentUser)\n  }\n\n  const handleGoogleLogin = () => oauthLogin('google')\n\n  const handleSignOut = async () => {\n    await logout()\n    setUser(null)\n  }\n\n  if (loading) return <p>Loading...</p>\n  // Render login form or user details based on `user` state\n}\n```\n\n## Error Handling\n\n`@netlify/identity` throws two error classes:\n\n- **`AuthError`** — Thrown by auth operations. Has `message`, optional `status` (HTTP status code), and optional `cause`.\n- **`MissingIdentityError`** — Thrown when Identity is not configured in the current environment.\n\n`getUser()` and `isAuthenticated()` never throw — they return `null` and `false` respectively on failure.\n\n| Status | Meaning |\n|--------|---------|\n| 401 | Invalid credentials or expired token |\n| 403 | Action not allowed (e.g., signups disabled) |\n| 422 | Validation error (e.g., weak password, malformed email) |\n| 404 | User or resource not found |\n\n## Identity Event Functions\n\nSpecial serverless functions that trigger on Identity lifecycle events. These use the **legacy named `handler` export** (not the modern default export).\n\n**Event names:** `identity-validate`, `identity-signup`, `identity-login`\n\n```typescript\n// netlify/functions/identity-signup.mts\nimport type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions'\n\nconst handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {\n  const { user } = JSON.parse(event.body || '{}')\n\n  return {\n    statusCode: 200,\n    body: JSON.stringify({\n      app_metadata: {\n        ...user.app_metadata,\n        roles: ['member'],\n      },\n    }),\n  }\n}\n\nexport { handler }\n```\n\nThe response body replaces `app_metadata` and/or `user_metadata` on the user record — include all fields you want to keep.\n\n## Roles and Authorization\n\n- **`app_metadata.roles`** — Server-controlled. Only settable via the Netlify UI, admin API, or Identity event functions. Never let users set their own roles.\n- **`user_metadata`** — User-controlled. Users can update via `updateUser({ data: { ... } })`.\n\n### Role-Based Redirects\n\n```toml\n# netlify.toml\n[[redirects]]\n  from = \"/admin/*\"\n  to = \"/admin/:splat\"\n  status = 200\n  conditions = { Role = [\"admin\"] }\n\n[[redirects]]\n  from = \"/admin/*\"\n  to = \"/\"\n  status = 302\n```\n\nRules are evaluated top-to-bottom. The `nf_jwt` cookie is read by the CDN to evaluate role conditions.\n\n## Bundled References (Load As Needed)\n\n- [Advanced patterns](references/advanced-patterns.md) — password recovery, invite acceptance, email change, session hydration, SSR integration\n"
AFTER
"---\nname: netlify-identity\ndescription: Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, \"auth middleware\" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead.\n---\n\n# Netlify Identity\n\nUse `@netlify/identity` (npm). For new projects it replaces the legacy `netlify-identity-widget` and `gotrue-js` — do not reach for those.\n\n```bash\nnpm install @netlify/identity\n```\n\nFramework examples (Next.js/Astro/Remix/SvelteKit) and the full API reference are in the [`@netlify/identity` README on npm](https://www.npmjs.com/package/@netlify/identity).\n\n> **Identity does not run under `netlify dev`.** Test all auth flows on a deploy — Deploy Previews work. Local dev will not complete signup/login/OAuth.\n\n> **Identity config is dashboard-only — there is no public API.** Never curl `api.netlify.com` to flip or read Identity settings, never read tokens from local Netlify config, never probe undocumented endpoints. Enable and configure Identity at `https://app.netlify.com/projects/{site_name}/identity`.\n\n> **Never build a from-scratch OAuth flow alongside Identity.** No provider app registration in code, no `client_id`/`secret` in source, no custom callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`. Raw OAuth beside Identity is the most common source of rework.\n\n## Client auth (browser)\n\n```ts\nimport { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'\n\n// Register — confirmation email sent by default (unless autoconfirm is on)\nconst user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })\n\n// Log in / out\nawait login('jane@example.com', 'securepassword')\nawait logout()\n\n// Current user or null\nconst current = await getUser()\nif (current) console.log(`Logged in as ${current.email}`)\n\n// External provider — redirects the browser; provider is one of\n// 'google' | 'github' | 'gitlab' | 'bitbucket'\noauthLogin('github')\n```\n\n> **`handleAuthCallback()` is mandatory on your landing page.** Without it, OAuth redirects, email-confirmation links, password-recovery links, and invite links never complete. Call it on page load:\n\n```ts\nimport { handleAuthCallback } from '@netlify/identity'\n\nconst result = await handleAuthCallback() // falsy if no token in URL hash\nif (result) console.log(result.type, result.user.email) // confirmation | invite | recovery | email change\n```\n\nAlternatives for a single token type: `recoverPassword()` (recovery), `acceptInvite()` (invite). Refresh a session with `refreshSession()`.\n\nDon't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.\n\n## Server-side auth (Netlify Functions & Edge Functions)\n\nServer-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.\n\n`getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions — not the browser, not Edge Functions.**\n\n```ts\n// netlify/functions/me.ts — verify user\nimport { getUser } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  const user = await getUser()\n  if (!user) return new Response('Unauthorized', { status: 401 })\n  return Response.json({ id: user.id, email: user.email })\n}\n```\n\nEdge Function form is identical but imports `Context` from `@netlify/edge-functions`.\n\n### Role checks\n\n```ts\n// netlify/functions/admin-users.ts\nimport { getUser, admin } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  const user = await getUser()\n  if (!user) return new Response('Unauthorized', { status: 401 })\n  if (!user.roles.includes('admin')) return new Response('Forbidden', { status: 403 })\n  const users = await admin.listUsers()\n  return Response.json({ users })\n}\n```\n\n### CSRF: required for server-side auth endpoints\n\n> Any endpoint that runs `login()`, `signup()`, or `logout()` server-side **must** call `verifyRequestOrigin(req)` at the top of the handler. It throws a 403 on origin mismatch.\n\n```ts\n// netlify/functions/login.ts\nimport { login, verifyRequestOrigin } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  verifyRequestOrigin(req)\n  const { email, password } = await req.json()\n  await login(email, password)\n  return new Response(null, { status: 302, headers: { Location: '/dashboard' } })\n}\n```\n\n## Identity event functions\n\nThe platform calls your handler when an Identity event occurs. Export a default object with a method per event. File: `netlify/functions/identity.mts`.\n\n> Typed handlers (`UserSignupEvent`, `event.deny()`) require `@netlify/functions` ≥ 5.2.0. Older installs must use the legacy filename convention (`identity-signup.ts`, etc.) — see `references/authorization-and-sessions.md`.\n\n| Handler | Fires when |\n|---|---|\n| `userValidate` | Signup attempt, before account creation. Block bad signups here. |\n| `userSignup` | Signup completes (after email confirmation if enabled). Assign roles, sync, welcome. |\n| `userLogin` | User logs in. Track/last-seen/block. |\n| `userModified` | Profile updated. |\n| `userDeleted` | User deleted (notification only). |\n\nEvent `user` fields are camelCase (`appMetadata`, `userMetadata`, `confirmedAt`).\n\n```typescript\n// netlify/functions/identity.mts — deny a signup\nimport type { UserValidateEvent } from \"@netlify/functions\"\n\nexport default {\n  userValidate(event: UserValidateEvent) {\n    if (!event.user.email?.endsWith(\"@example.com\")) return event.deny()\n  },\n}\n```\n\n```typescript\n// netlify/functions/identity.mts — assign roles at signup\nimport type { UserSignupEvent } from \"@netlify/functions\"\n\nexport default {\n  userSignup(event: UserSignupEvent) {\n    return { user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: [\"member\"] } } }\n  },\n}\n```\n\n- `event.deny()` — rejects the action; end user gets `401`, no observability error. First handler to call it aborts the chain; later subscribers are not invoked. (Legacy filename functions signal denial with a non-2xx `Response` instead.)\n- Return `{ user: {...} }` to modify the record before persistence (canonical way to set roles at signup). Roles ride in the JWT, so a role change takes effect on the user's **next login or token refresh, not immediately** — see Roles & the JWT below.\n- Background mode: `export const config: Config = { background: true }` — action completes immediately, handler runs async.\n\n## Roles & the JWT\n\n- `user.roles` is read from `app_metadata.roles`, carried in the JWT (cookie `nf_jwt`; refresh via `nf_refresh`).\n- `user_metadata` — user-editable profile (`full_name`, `email`). `app_metadata` — app data incl. `roles`, not user-editable.\n\n> **Role changes are NOT immediate.** They take effect on next login or token refresh. Changing roles does not invalidate the current JWT. Force it with `refreshSession()`.\n\nSet roles for existing users via `admin.updateUser()` in a Netlify Function; at signup via the `userSignup` event handler above.\n\nDeep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.\n\n## CDN-edge RBAC (redirect rules)\n\nEnforced at the edge with no origin round trip. A mismatched role gets a 404 unless you add a fallback — **always pair a role-gated rule with a fallback.**\n\n`_redirects`:\n```\n/admin/*  /admin/:splat  200!  Role=admin\n/admin/*  /login         401!\n# Multiple roles chained with commas:\n/private/* /private/:splat 200! Role=editor,admin\n```\n\n`netlify.toml`:\n```toml\n[[redirects]]\n  from = \"/admin/*\"\n  to = \"/admin/:splat\"\n  force = true\n  status = 200\n  conditions = {Role = [\"editor\", \"admin\"]}\n```\n\nUse redirect rules for path-based gating; use function-based `user.roles` checks for custom authorization logic.\n\n## Configuration (dashboard-only)\n\nBase: `https://app.netlify.com/projects/{site_name}/identity`. Enable with **Enable Identity**. Identity requires HTTPS — set up SSL before integrating on a custom domain.\n\n- **Registration** (`?tab=registration#registration-preferences`): **Open** (default, anyone can sign up) or **Invite only** (all users, including external-provider logins, must be invited first).\n- **Confirmation / autoconfirm** (`?tab=emails#confirmation-template`): check the box to skip email verification.\n- **External providers** (`?tab=registration#external-providers`): Google/GitHub/GitLab/Bitbucket. For branded OAuth (your app name instead of \"Netlify Identity\"), register your app with the provider, get client ID + secret, and enter them **in the Netlify settings UI** — not in code.\n- **Invitations** (`?tab=users`): enter addresses to send invites; link carries `invite_token`.\n- **Password recovery**: user page → **Send reset password email**; link carries `recovery_token`.\n\n### Emails (Pro plans or higher)\n\nDefault sender is `no-reply@netlify.com`. Custom SMTP sender and custom templates both require **Pro plans or higher**.\n\nTemplate variables (Go syntax): `{{ .Email }}`, `{{ .NewEmail }}` (email-change only), `{{ .SiteURL }}`, `{{ .ConfirmationURL }}`, `{{ .Token }}`.\n\nCustom-link hash fragments per action:\n```\n{{ .SiteURL }}/path/#invite_token={{ .Token }}\n{{ .SiteURL }}/path/#confirmation_token={{ .Token }}\n{{ .SiteURL }}/path/#recovery_token={{ .Token }}\n{{ .SiteURL }}/path/#email_change_token={{ .Token }}\n```\n\nCustom template constraints: inline CSS only; absolute image links; **no `<html>`/`<head>`/`<body>` tags**; ensure your build doesn't alter Go template variables.\n\n### Audit log (Pro plans or higher)\n\n`?tab=audit-log`. Search with a scoped term: `author:[string]` or `action:[string]`. Action names: `login`, `logout`, `user_signedup`, `user_deleted`, `user_modified`, `token_revoked`, `token_refreshed`, `user_recovery_requested`, `user_invited`.\n\n## External JWT providers (Enterprise)\n\nAvailable on **Enterprise plans**. You may use Netlify Identity OR an external JWT provider — **not both at once**; you cannot authenticate third-party JWTs while Netlify Identity is enabled.\n\n- Roles path: Netlify Identity `app_metadata.roles`; external provider `app_metadata.authorization.roles`. Custom path → contact support.\n- JWT header must be `{\"alg\": \"HS256\", \"typ\": \"JWT\"}` (HS256 required). Payload `exp` is required and must be a future Unix Epoch time.\n- Set the JWT secret at `Project configuration > General > Visitor access > JWT secret`. Project-level overrides team-level defaults.\n\n## On failure — stop, don't guess\n\nIf callbacks 404, `/.netlify/identity/*` is unreachable, or an OAuth flow never returns: surface the error, the dashboard URL (`https://app.netlify.com/projects/{site_name}/identity`), and the setting to check (registration preference, external provider config, confirmation/autoconfirm). Then stop. Do not invent recovery commands. Remember: Identity does not work under `netlify dev` — confirm you are testing on a deploy.\n\nSite-gating requests (\"lock this site to my company\", employees-only) route to the netlify-access-control skill first — Identity is the app-level user layer only.\n\n<!-- gap: getSettings() is referenced by house rules for provider discovery but its signature/return shape is not documented in the intermediate. -->\n\n<!-- system: agent-context/identity/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->\n# Netlify house rules (identity)\n\nThese are org conventions, not docs facts — merged into the rendered skill by\nctx-gen and never generated. Owned by the skills maintainer.\n\n1. Deep guides live in this skill: `references/advanced-patterns.md`\n   (SSR/session hydration) and `references/authorization-and-sessions.md`.\n2. Identity does not work under `netlify dev` — test auth flows on deploys\n   (Deploy Previews work).\n3. Identity configuration has no public API — it is dashboard-only. Never curl\n   `api.netlify.com` to flip or inspect Identity settings, never read auth\n   tokens from `~/Library/Preferences/netlify/config.json`, never probe for\n   undocumented endpoints.\n4. On failure (callback 404s, `/.netlify/identity/*` unreachable, OAuth flow\n   doesn't return), surface the error, the dashboard URL, and the setting to\n   check — then stop. Do not invent recovery commands.\n5. Never build a from-scratch third-party OAuth flow when Identity is in play —\n   no provider app registration, no `client_id`/`secret` in code, no custom\n   callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`;\n   raw OAuth beside Identity is the single most common source of rework.\n6. Server-side `getUser()`/`login()`/`admin.*` require modern v2 functions\n   (`export default`) — v1 `export { handler }` is not supported. Typed\n   Identity event handlers (`UserSignupEvent`, `event.deny()`) require\n   `@netlify/functions` ≥ 5.2.0; older installs use the legacy filenames.\n7. Don't hard-code which auth providers exist — call `getSettings()` at\n   startup and render the signup form and OAuth buttons from what it returns.\n8. Site-gating requests (\"lock this site to my company\", employees-only)\n   route to the netlify-access-control skill first — Identity is the\n   app-level user layer only.\n9. Any answer that assigns or changes roles — at signup, via `admin.*`, or in\n   the dashboard — must say the change takes effect on the user's next login\n   or token refresh, not immediately. Keep that sentence next to the code that\n   sets the role, not only in a separate JWT section: an agent answering a\n   signup question reads the signup example and stops, and it has shipped\n   answers that omit the delay.\n"

SKILL.md line diff

--- before
+++ after
@@ -1,47 +1,66 @@
 ---
 name: netlify-identity
-description: Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `gotrue-js` — they are deprecated.
+description: Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, "auth middleware" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead.
 ---
 
 # 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).
-
-**Always use `@netlify/identity`.** Never use `netlify-identity-widget` or `gotrue-js` — they are deprecated. `@netlify/identity` provides a unified, headless TypeScript API that works in both browser and server contexts (Netlify Functions, Edge Functions, SSR frameworks).
-
-## Setup
+Use `@netlify/identity` (npm). For new projects it replaces the legacy `netlify-identity-widget` and `gotrue-js` — do not reach for those.
 
 ```bash
 npm install @netlify/identity
 ```
 
-Identity is automatically enabled when a deploy created by a Netlify Agent Runner session includes Identity code. Otherwise, it must be manually enabled in the UI. These are the default settings:
+Framework examples (Next.js/Astro/Remix/SvelteKit) and the full API reference are in the [`@netlify/identity` README on npm](https://www.npmjs.com/package/@netlify/identity).
 
-- **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.
+> **Identity does not run under `netlify dev`.** Test all auth flows on a deploy — Deploy Previews work. Local dev will not complete signup/login/OAuth.
 
-### Local Development
+> **Identity config is dashboard-only — there is no public API.** Never curl `api.netlify.com` to flip or read Identity settings, never read tokens from local Netlify config, never probe undocumented endpoints. Enable and configure Identity at `https://app.netlify.com/projects/{site_name}/identity`.
 
-Identity does **not** currently work with `netlify dev`. You must deploy to Netlify to test Identity features. Use `npx netlify deploy` for preview deploys during development. This limitation may be resolved in a future release.
+> **Never build a from-scratch OAuth flow alongside Identity.** No provider app registration in code, no `client_id`/`secret` in source, no custom callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`. Raw OAuth beside Identity is the most common source of rework.
 
-## Quick Start
+## Client auth (browser)
 
-Log in from the browser:
+```ts
+import { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'
 
-```typescript
-import { login, getUser } from '@netlify/identity'
+// Register — confirmation email sent by default (unless autoconfirm is on)
+const user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })
+
+// Log in / out
+await login('jane@example.com', 'securepassword')
+await logout()
 
-const user = await login('user@example.com', '<password>')
-console.log(`Hello, ${user.name}`)
+// Current user or null
+const current = await getUser()
+if (current) console.log(`Logged in as ${current.email}`)
 
-// Later, check auth state
-const currentUser = await getUser()
+// External provider — redirects the browser; provider is one of
+// 'google' | 'github' | 'gitlab' | 'bitbucket'
+oauthLogin('github')
 ```
 
-Protect a Netlify Function:
+> **`handleAuthCallback()` is mandatory on your landing page.** Without it, OAuth redirects, email-confirmation links, password-recovery links, and invite links never complete. Call it on page load:
 
-```typescript
-// netlify/functions/protected.mts
+```ts
+import { handleAuthCallback } from '@netlify/identity'
+
+const result = await handleAuthCallback() // falsy if no token in URL hash
+if (result) console.log(result.type, result.user.email) // confirmation | invite | recovery | email change
+```
+
+Alternatives for a single token type: `recoverPassword()` (recovery), `acceptInvite()` (invite). Refresh a session with `refreshSession()`.
+
+Don't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.
+
+## Server-side auth (Netlify Functions & Edge Functions)
+
+Server-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.
+
+`getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions — not the browser, not Edge Functions.**
+
+```ts
+// netlify/functions/me.ts — verify user
 import { getUser } from '@netlify/identity'
 import type { Context } from '@netlify/functions'
 
@@ -52,284 +71,197 @@
 }
 ```
 
-## Core API
-
-Import and use headless functions directly:
+Edge Function form is identical but imports `Context` from `@netlify/edge-functions`.
 
-```typescript
-import {
-  getUser,
-  handleAuthCallback,
-  login,
-  logout,
-  signup,
-  oauthLogin,
-  onAuthChange,
-  getSettings,
-} from '@netlify/identity'
-```
+### Role checks
 
-### Login
-
-```typescript
-import { login, AuthError } from '@netlify/identity'
+```ts
+// netlify/functions/admin-users.ts
+import { getUser, admin } from '@netlify/identity'
+import type { Context } from '@netlify/functions'
 
-async function handleLogin(email: string, password: string) {
-  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)
-    }
-  }
+export default async (req: Request, context: Context) => {
+  const user = await getUser()
+  if (!user) return new Response('Unauthorized', { status: 401 })
+  if (!user.roles.includes('admin')) return new Response('Forbidden', { status: 403 })
+  const users = await admin.listUsers()
+  return Response.json({ users })
 }
 ```
 
-### Signup
+### CSRF: required for server-side auth endpoints
 
-After signup, check `user.emailVerified` to determine if the user was auto-confirmed or needs to confirm their email.
+> Any endpoint that runs `login()`, `signup()`, or `logout()` server-side **must** call `verifyRequestOrigin(req)` at the top of the handler. It throws a 403 on origin mismatch.
 
-```typescript
-import { signup, AuthError } from '@netlify/identity'
+```ts
+// netlify/functions/login.ts
+import { login, verifyRequestOrigin } from '@netlify/identity'
+import type { Context } from '@netlify/functions'
 
-async function handleSignup(email: string, password: string, name: string) {
-  try {
-    const user = await signup(email, password, { full_name: name })
-    if (user.emailVerified) {
-      // Autoconfirm ON — user is logged in immediately
-      showSuccess('Account created. You are now logged in.')
-    } else {
-      // Autoconfirm OFF — confirmation email sent
-      showSuccess('Check your email to confirm your account.')
-    }
-  } catch (error) {
-    if (error instanceof AuthError) {
-      showError(error.status === 403 ? 'Signups are not allowed.' : error.message)
-    }
-  }
+export default async (req: Request, context: Context) => {
+  verifyRequestOrigin(req)
+  const { email, password } = await req.json()
+  await login(email, password)
+  return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
 }
 ```
 
-### Logout
+## Identity event functions
 
-```typescript
-import { logout } from '@netlify/identity'
+The platform calls your handler when an Identity event occurs. Export a default object with a method per event. File: `netlify/functions/identity.mts`.
 
-await logout()
-```
+> Typed handlers (`UserSignupEvent`, `event.deny()`) require `@netlify/functions` ≥ 5.2.0. Older installs must use the legacy filename convention (`identity-signup.ts`, etc.) — see `references/authorization-and-sessions.md`.
 
-### OAuth
+| Handler | Fires when |
+|---|---|
+| `userValidate` | Signup attempt, before account creation. Block bad signups here. |
+| `userSignup` | Signup completes (after email confirmation if enabled). Assign roles, sync, welcome. |
+| `userLogin` | User logs in. Track/last-seen/block. |
+| `userModified` | Profile updated. |
+| `userDeleted` | User deleted (notification only). |
 
-OAuth is a two-step flow: `oauthLogin(provider)` redirects away from the site, then `handleAuthCallback()` processes the redirect when the user returns.
+Event `user` fields are camelCase (`appMetadata`, `userMetadata`, `confirmedAt`).
 
 ```typescript
-import { oauthLogin } from '@netlify/identity'
+// netlify/functions/identity.mts — deny a signup
+import type { UserValidateEvent } from "@netlify/functions"
 
-// Step 1: Redirect to provider (navigates away — never returns)
-function handleOAuthClick(provider: 'google' | 'github' | 'gitlab' | 'bitbucket') {
-  oauthLogin(provider)
+export default {
+  userValidate(event: UserValidateEvent) {
+    if (!event.user.email?.endsWith("@example.com")) return event.deny()
+  },
 }
 ```
 
-Enable providers in **Project configuration > Identity > External providers** before using OAuth.
-
-### Handling Callbacks
-
-Always call `handleAuthCallback()` on page load in any app that uses OAuth, password recovery, invites, or email confirmation. It processes all callback types via the URL hash.
-
 ```typescript
-import { handleAuthCallback, AuthError } from '@netlify/identity'
+// netlify/functions/identity.mts — assign roles at signup
+import type { UserSignupEvent } from "@netlify/functions"
 
-async function processCallback() {
-  try {
-    const result = await handleAuthCallback()
-    if (!result) return // No callback hash — 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)
-  }
+export default {
+  userSignup(event: UserSignupEvent) {
+    return { user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: ["member"] } } }
+  },
 }
 ```
 
-### Auth State
-
-```typescript
-import { getUser, onAuthChange, AUTH_EVENTS } from '@netlify/identity'
+- `event.deny()` — rejects the action; end user gets `401`, no observability error. First handler to call it aborts the chain; later subscribers are not invoked. (Legacy filename functions signal denial with a non-2xx `Response` instead.)
+- Return `{ user: {...} }` to modify the record before persistence (canonical way to set roles at signup). Roles ride in the JWT, so a role change takes effect on the user's **next login or token refresh, not immediately** — see Roles & the JWT below.
+- Background mode: `export const config: Config = { background: true }` — action completes immediately, handler runs async.
 
-// Check current user (never throws — returns null if not authenticated)
-const user = await getUser()
+## Roles & the JWT
 
-// Subscribe to auth state changes (returns unsubscribe function)
-const unsubscribe = onAuthChange((event, user) => {
-  switch (event) {
-    case AUTH_EVENTS.LOGIN:
-      console.log('Logged in:', user?.email)
-      break
-    case AUTH_EVENTS.LOGOUT:
-      console.log('Logged out')
-      break
-    case AUTH_EVENTS.TOKEN_REFRESH:
-      break
-    case AUTH_EVENTS.USER_UPDATED:
-      console.log('Profile updated:', user?.email)
-      break
-    case AUTH_EVENTS.RECOVERY:
-      console.log('Password recovery initiated')
-      break
-  }
-})
-```
+- `user.roles` is read from `app_metadata.roles`, carried in the JWT (cookie `nf_jwt`; refresh via `nf_refresh`).
+- `user_metadata` — user-editable profile (`full_name`, `email`). `app_metadata` — app data incl. `roles`, not user-editable.
 
-### Settings-Driven UI
+> **Role changes are NOT immediate.** They take effect on next login or token refresh. Changing roles does not invalidate the current JWT. Force it with `refreshSession()`.
 
-Fetch the project's Identity settings to conditionally render signup forms and OAuth buttons.
+Set roles for existing users via `admin.updateUser()` in a Netlify Function; at signup via the `userSignup` event handler above.
 
-```typescript
-import { getSettings } from '@netlify/identity'
+Deep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.
 
-const settings = await getSettings()
-// settings.autoconfirm — boolean
-// settings.disableSignup — boolean
-// settings.providers — Record<AuthProvider, boolean>
+## CDN-edge RBAC (redirect rules)
 
-if (!settings.disableSignup) showSignupForm()
+Enforced at the edge with no origin round trip. A mismatched role gets a 404 unless you add a fallback — **always pair a role-gated rule with a fallback.**
 
-for (const [provider, enabled] of Object.entries(settings.providers)) {
-  if (enabled) showOAuthButton(provider)
-}
+`_redirects`:
+```
+/admin/*  /admin/:splat  200!  Role=admin
+/admin/*  /login         401!
+# Multiple roles chained with commas:
+/private/* /private/:splat 200! Role=editor,admin
 ```
 
-## Minimal React Example
-
-```tsx
-import { useEffect, useState } from 'react'
-import {
-  getUser,
-  handleAuthCallback,
-  login,
-  logout,
-  oauthLogin,
-  onAuthChange,
-} from '@netlify/identity'
-
-function App() {
-  const [user, setUser] = useState(null)
-  const [loading, setLoading] = useState(true)
-
-  useEffect(() => {
-    ;(async () => {
-      await handleAuthCallback()
-      setUser(await getUser())
-      setLoading(false)
-    })()
-    return onAuthChange((_event, currentUser) => setUser(currentUser))
-  }, [])
-
-  const handleLogin = async (email, password) => {
-    const currentUser = await login(email, password)
-    setUser(currentUser)
-  }
-
-  const handleGoogleLogin = () => oauthLogin('google')
-
-  const handleSignOut = async () => {
-    await logout()
-    setUser(null)
-  }
-
-  if (loading) return <p>Loading...</p>
-  // Render login form or user details based on `user` state
-}
+`netlify.toml`:
+```toml
+[[redirects]]
+  from = "/admin/*"
+  to = "/admin/:splat"
+  force = true
+  status = 200
+  conditions = {Role = ["editor", "admin"]}
 ```
 
-## Error Handling
+Use redirect rules for path-based gating; use function-based `user.roles` checks for custom authorization logic.
 
-`@netlify/identity` throws two error classes:
+## Configuration (dashboard-only)
 
-- **`AuthError`** — Thrown by auth operations. Has `message`, optional `status` (HTTP status code), and optional `cause`.
-- **`MissingIdentityError`** — Thrown when Identity is not configured in the current environment.
+Base: `https://app.netlify.com/projects/{site_name}/identity`. Enable with **Enable Identity**. Identity requires HTTPS — set up SSL before integrating on a custom domain.
 
-`getUser()` and `isAuthenticated()` never throw — they return `null` and `false` respectively on failure.
+- **Registration** (`?tab=registration#registration-preferences`): **Open** (default, anyone can sign up) or **Invite only** (all users, including external-provider logins, must be invited first).
+- **Confirmation / autoconfirm** (`?tab=emails#confirmation-template`): check the box to skip email verification.
+- **External providers** (`?tab=registration#external-providers`): Google/GitHub/GitLab/Bitbucket. For branded OAuth (your app name instead of "Netlify Identity"), register your app with the provider, get client ID + secret, and enter them **in the Netlify settings UI** — not in code.
+- **Invitations** (`?tab=users`): enter addresses to send invites; link carries `invite_token`.
+- **Password recovery**: user page → **Send reset password email**; link carries `recovery_token`.
 
-| 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 |
+### Emails (Pro plans or higher)
 
-## Identity Event Functions
+Default sender is `no-reply@netlify.com`. Custom SMTP sender and custom templates both require **Pro plans or higher**.
 
-Special serverless functions that trigger on Identity lifecycle events. These use the **legacy named `handler` export** (not the modern default export).
+Template variables (Go syntax): `{{ .Email }}`, `{{ .NewEmail }}` (email-change only), `{{ .SiteURL }}`, `{{ .ConfirmationURL }}`, `{{ .Token }}`.
 
-**Event names:** `identity-validate`, `identity-signup`, `identity-login`
+Custom-link hash fragments per action:
+```
+{{ .SiteURL }}/path/#invite_token={{ .Token }}
+{{ .SiteURL }}/path/#confirmation_token={{ .Token }}
+{{ .SiteURL }}/path/#recovery_token={{ .Token }}
+{{ .SiteURL }}/path/#email_change_token={{ .Token }}
+```
 
-```typescript
-// netlify/functions/identity-signup.mts
-import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions'
+Custom template constraints: inline CSS only; absolute image links; **no `<html>`/`<head>`/`<body>` tags**; ensure your build doesn't alter Go template variables.
 
-const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
-  const { user } = JSON.parse(event.body || '{}')
+### Audit log (Pro plans or higher)
 
-  return {
-    statusCode: 200,
-    body: JSON.stringify({
-      app_metadata: {
-        ...user.app_metadata,
-        roles: ['member'],
-      },
-    }),
-  }
-}
+`?tab=audit-log`. Search with a scoped term: `author:[string]` or `action:[string]`. Action names: `login`, `logout`, `user_signedup`, `user_deleted`, `user_modified`, `token_revoked`, `token_refreshed`, `user_recovery_requested`, `user_invited`.
 
-export { handler }
-```
+## External JWT providers (Enterprise)
 
-The response body replaces `app_metadata` and/or `user_metadata` on the user record — include all fields you want to keep.
+Available on **Enterprise plans**. You may use Netlify Identity OR an external JWT provider — **not both at once**; you cannot authenticate third-party JWTs while Netlify Identity is enabled.
 
-## Roles and Authorization
+- Roles path: Netlify Identity `app_metadata.roles`; external provider `app_metadata.authorization.roles`. Custom path → contact support.
+- JWT header must be `{"alg": "HS256", "typ": "JWT"}` (HS256 required). Payload `exp` is required and must be a future Unix Epoch time.
+- Set the JWT secret at `Project configuration > General > Visitor access > JWT secret`. Project-level overrides team-level defaults.
 
-- **`app_metadata.roles`** — Server-controlled. Only settable via the Netlify UI, admin API, or Identity event functions. Never let users set their own roles.
-- **`user_metadata`** — User-controlled. Users can update via `updateUser({ data: { ... } })`.
+## On failure — stop, don't guess
 
-### Role-Based Redirects
+If callbacks 404, `/.netlify/identity/*` is unreachable, or an OAuth flow never returns: surface the error, the dashboard URL (`https://app.netlify.com/projects/{site_name}/identity`), and the setting to check (registration preference, external provider config, confirmation/autoconfirm). Then stop. Do not invent recovery commands. Remember: Identity does not work under `netlify dev` — confirm you are testing on a deploy.
 
-```toml
-# netlify.toml
-[[redirects]]
-  from = "/admin/*"
-  to = "/admin/:splat"
-  status = 200
-  conditions = { Role = ["admin"] }
+Site-gating requests ("lock this site to my company", employees-only) route to the netlify-access-control skill first — Identity is the app-level user layer only.
 
-[[redirects]]
-  from = "/admin/*"
-  to = "/"
-  status = 302
-```
+<!-- gap: getSettings() is referenced by house rules for provider discovery but its signature/return shape is not documented in the intermediate. -->
 
-Rules are evaluated top-to-bottom. The `nf_jwt` cookie is read by the CDN to evaluate role conditions.
+<!-- system: agent-context/identity/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
+# Netlify house rules (identity)
 
-## Bundled References (Load As Needed)
+These are org conventions, not docs facts — merged into the rendered skill by
+ctx-gen and never generated. Owned by the skills maintainer.
 
-- [Advanced patterns](references/advanced-patterns.md) — password recovery, invite acceptance, email change, session hydration, SSR integration
+1. Deep guides live in this skill: `references/advanced-patterns.md`
+   (SSR/session hydration) and `references/authorization-and-sessions.md`.
+2. Identity does not work under `netlify dev` — test auth flows on deploys
+   (Deploy Previews work).
+3. Identity configuration has no public API — it is dashboard-only. Never curl
+   `api.netlify.com` to flip or inspect Identity settings, never read auth
+   tokens from `~/Library/Preferences/netlify/config.json`, never probe for
+   undocumented endpoints.
+4. On failure (callback 404s, `/.netlify/identity/*` unreachable, OAuth flow
+   doesn't return), surface the error, the dashboard URL, and the setting to
+   check — then stop. Do not invent recovery commands.
+5. Never build a from-scratch third-party OAuth flow when Identity is in play —
+   no provider app registration, no `client_id`/`secret` in code, no custom
+   callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`;
+   raw OAuth beside Identity is the single most common source of rework.
+6. Server-side `getUser()`/`login()`/`admin.*` require modern v2 functions
+   (`export default`) — v1 `export { handler }` is not supported. Typed
+   Identity event handlers (`UserSignupEvent`, `event.deny()`) require
+   `@netlify/functions` ≥ 5.2.0; older installs use the legacy filenames.
+7. Don't hard-code which auth providers exist — call `getSettings()` at
+   startup and render the signup form and OAuth buttons from what it returns.
+8. Site-gating requests ("lock this site to my company", employees-only)
+   route to the netlify-access-control skill first — Identity is the
+   app-level user layer only.
+9. Any answer that assigns or changes roles — at signup, via `admin.*`, or in
+   the dashboard — must say the change takes effect on the user's next login
+   or token refresh, not immediately. Keep that sentence next to the code that
+   sets the role, not only in a separate JWT section: an agent answering a
+   signup question reads the signup example and stops, and it has shipped
+   answers that omit the delay.
Full snapshot data
{
  "description": "Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, \"auth middleware\" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead.",
  "included_files": [
    {
      "relative_path": "references/advanced-patterns.md",
      "size_in_bytes": 4428
    },
    {
      "relative_path": "references/authorization-and-sessions.md",
      "size_in_bytes": 3384
    }
  ],
  "name": "netlify-identity",
  "skill_md_contents": "---\nname: netlify-identity\ndescription: Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, \"auth middleware\" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead.\n---\n\n# Netlify Identity\n\nUse `@netlify/identity` (npm). For new projects it replaces the legacy `netlify-identity-widget` and `gotrue-js` — do not reach for those.\n\n```bash\nnpm install @netlify/identity\n```\n\nFramework examples (Next.js/Astro/Remix/SvelteKit) and the full API reference are in the [`@netlify/identity` README on npm](https://www.npmjs.com/package/@netlify/identity).\n\n> **Identity does not run under `netlify dev`.** Test all auth flows on a deploy — Deploy Previews work. Local dev will not complete signup/login/OAuth.\n\n> **Identity config is dashboard-only — there is no public API.** Never curl `api.netlify.com` to flip or read Identity settings, never read tokens from local Netlify config, never probe undocumented endpoints. Enable and configure Identity at `https://app.netlify.com/projects/{site_name}/identity`.\n\n> **Never build a from-scratch OAuth flow alongside Identity.** No provider app registration in code, no `client_id`/`secret` in source, no custom callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`. Raw OAuth beside Identity is the most common source of rework.\n\n## Client auth (browser)\n\n```ts\nimport { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'\n\n// Register — confirmation email sent by default (unless autoconfirm is on)\nconst user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })\n\n// Log in / out\nawait login('jane@example.com', 'securepassword')\nawait logout()\n\n// Current user or null\nconst current = await getUser()\nif (current) console.log(`Logged in as ${current.email}`)\n\n// External provider — redirects the browser; provider is one of\n// 'google' | 'github' | 'gitlab' | 'bitbucket'\noauthLogin('github')\n```\n\n> **`handleAuthCallback()` is mandatory on your landing page.** Without it, OAuth redirects, email-confirmation links, password-recovery links, and invite links never complete. Call it on page load:\n\n```ts\nimport { handleAuthCallback } from '@netlify/identity'\n\nconst result = await handleAuthCallback() // falsy if no token in URL hash\nif (result) console.log(result.type, result.user.email) // confirmation | invite | recovery | email change\n```\n\nAlternatives for a single token type: `recoverPassword()` (recovery), `acceptInvite()` (invite). Refresh a session with `refreshSession()`.\n\nDon't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.\n\n## Server-side auth (Netlify Functions & Edge Functions)\n\nServer-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.\n\n`getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions — not the browser, not Edge Functions.**\n\n```ts\n// netlify/functions/me.ts — verify user\nimport { getUser } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  const user = await getUser()\n  if (!user) return new Response('Unauthorized', { status: 401 })\n  return Response.json({ id: user.id, email: user.email })\n}\n```\n\nEdge Function form is identical but imports `Context` from `@netlify/edge-functions`.\n\n### Role checks\n\n```ts\n// netlify/functions/admin-users.ts\nimport { getUser, admin } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  const user = await getUser()\n  if (!user) return new Response('Unauthorized', { status: 401 })\n  if (!user.roles.includes('admin')) return new Response('Forbidden', { status: 403 })\n  const users = await admin.listUsers()\n  return Response.json({ users })\n}\n```\n\n### CSRF: required for server-side auth endpoints\n\n> Any endpoint that runs `login()`, `signup()`, or `logout()` server-side **must** call `verifyRequestOrigin(req)` at the top of the handler. It throws a 403 on origin mismatch.\n\n```ts\n// netlify/functions/login.ts\nimport { login, verifyRequestOrigin } from '@netlify/identity'\nimport type { Context } from '@netlify/functions'\n\nexport default async (req: Request, context: Context) => {\n  verifyRequestOrigin(req)\n  const { email, password } = await req.json()\n  await login(email, password)\n  return new Response(null, { status: 302, headers: { Location: '/dashboard' } })\n}\n```\n\n## Identity event functions\n\nThe platform calls your handler when an Identity event occurs. Export a default object with a method per event. File: `netlify/functions/identity.mts`.\n\n> Typed handlers (`UserSignupEvent`, `event.deny()`) require `@netlify/functions` ≥ 5.2.0. Older installs must use the legacy filename convention (`identity-signup.ts`, etc.) — see `references/authorization-and-sessions.md`.\n\n| Handler | Fires when |\n|---|---|\n| `userValidate` | Signup attempt, before account creation. Block bad signups here. |\n| `userSignup` | Signup completes (after email confirmation if enabled). Assign roles, sync, welcome. |\n| `userLogin` | User logs in. Track/last-seen/block. |\n| `userModified` | Profile updated. |\n| `userDeleted` | User deleted (notification only). |\n\nEvent `user` fields are camelCase (`appMetadata`, `userMetadata`, `confirmedAt`).\n\n```typescript\n// netlify/functions/identity.mts — deny a signup\nimport type { UserValidateEvent } from \"@netlify/functions\"\n\nexport default {\n  userValidate(event: UserValidateEvent) {\n    if (!event.user.email?.endsWith(\"@example.com\")) return event.deny()\n  },\n}\n```\n\n```typescript\n// netlify/functions/identity.mts — assign roles at signup\nimport type { UserSignupEvent } from \"@netlify/functions\"\n\nexport default {\n  userSignup(event: UserSignupEvent) {\n    return { user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: [\"member\"] } } }\n  },\n}\n```\n\n- `event.deny()` — rejects the action; end user gets `401`, no observability error. First handler to call it aborts the chain; later subscribers are not invoked. (Legacy filename functions signal denial with a non-2xx `Response` instead.)\n- Return `{ user: {...} }` to modify the record before persistence (canonical way to set roles at signup). Roles ride in the JWT, so a role change takes effect on the user's **next login or token refresh, not immediately** — see Roles & the JWT below.\n- Background mode: `export const config: Config = { background: true }` — action completes immediately, handler runs async.\n\n## Roles & the JWT\n\n- `user.roles` is read from `app_metadata.roles`, carried in the JWT (cookie `nf_jwt`; refresh via `nf_refresh`).\n- `user_metadata` — user-editable profile (`full_name`, `email`). `app_metadata` — app data incl. `roles`, not user-editable.\n\n> **Role changes are NOT immediate.** They take effect on next login or token refresh. Changing roles does not invalidate the current JWT. Force it with `refreshSession()`.\n\nSet roles for existing users via `admin.updateUser()` in a Netlify Function; at signup via the `userSignup` event handler above.\n\nDeep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.\n\n## CDN-edge RBAC (redirect rules)\n\nEnforced at the edge with no origin round trip. A mismatched role gets a 404 unless you add a fallback — **always pair a role-gated rule with a fallback.**\n\n`_redirects`:\n```\n/admin/*  /admin/:splat  200!  Role=admin\n/admin/*  /login         401!\n# Multiple roles chained with commas:\n/private/* /private/:splat 200! Role=editor,admin\n```\n\n`netlify.toml`:\n```toml\n[[redirects]]\n  from = \"/admin/*\"\n  to = \"/admin/:splat\"\n  force = true\n  status = 200\n  conditions = {Role = [\"editor\", \"admin\"]}\n```\n\nUse redirect rules for path-based gating; use function-based `user.roles` checks for custom authorization logic.\n\n## Configuration (dashboard-only)\n\nBase: `https://app.netlify.com/projects/{site_name}/identity`. Enable with **Enable Identity**. Identity requires HTTPS — set up SSL before integrating on a custom domain.\n\n- **Registration** (`?tab=registration#registration-preferences`): **Open** (default, anyone can sign up) or **Invite only** (all users, including external-provider logins, must be invited first).\n- **Confirmation / autoconfirm** (`?tab=emails#confirmation-template`): check the box to skip email verification.\n- **External providers** (`?tab=registration#external-providers`): Google/GitHub/GitLab/Bitbucket. For branded OAuth (your app name instead of \"Netlify Identity\"), register your app with the provider, get client ID + secret, and enter them **in the Netlify settings UI** — not in code.\n- **Invitations** (`?tab=users`): enter addresses to send invites; link carries `invite_token`.\n- **Password recovery**: user page → **Send reset password email**; link carries `recovery_token`.\n\n### Emails (Pro plans or higher)\n\nDefault sender is `no-reply@netlify.com`. Custom SMTP sender and custom templates both require **Pro plans or higher**.\n\nTemplate variables (Go syntax): `{{ .Email }}`, `{{ .NewEmail }}` (email-change only), `{{ .SiteURL }}`, `{{ .ConfirmationURL }}`, `{{ .Token }}`.\n\nCustom-link hash fragments per action:\n```\n{{ .SiteURL }}/path/#invite_token={{ .Token }}\n{{ .SiteURL }}/path/#confirmation_token={{ .Token }}\n{{ .SiteURL }}/path/#recovery_token={{ .Token }}\n{{ .SiteURL }}/path/#email_change_token={{ .Token }}\n```\n\nCustom template constraints: inline CSS only; absolute image links; **no `<html>`/`<head>`/`<body>` tags**; ensure your build doesn't alter Go template variables.\n\n### Audit log (Pro plans or higher)\n\n`?tab=audit-log`. Search with a scoped term: `author:[string]` or `action:[string]`. Action names: `login`, `logout`, `user_signedup`, `user_deleted`, `user_modified`, `token_revoked`, `token_refreshed`, `user_recovery_requested`, `user_invited`.\n\n## External JWT providers (Enterprise)\n\nAvailable on **Enterprise plans**. You may use Netlify Identity OR an external JWT provider — **not both at once**; you cannot authenticate third-party JWTs while Netlify Identity is enabled.\n\n- Roles path: Netlify Identity `app_metadata.roles`; external provider `app_metadata.authorization.roles`. Custom path → contact support.\n- JWT header must be `{\"alg\": \"HS256\", \"typ\": \"JWT\"}` (HS256 required). Payload `exp` is required and must be a future Unix Epoch time.\n- Set the JWT secret at `Project configuration > General > Visitor access > JWT secret`. Project-level overrides team-level defaults.\n\n## On failure — stop, don't guess\n\nIf callbacks 404, `/.netlify/identity/*` is unreachable, or an OAuth flow never returns: surface the error, the dashboard URL (`https://app.netlify.com/projects/{site_name}/identity`), and the setting to check (registration preference, external provider config, confirmation/autoconfirm). Then stop. Do not invent recovery commands. Remember: Identity does not work under `netlify dev` — confirm you are testing on a deploy.\n\nSite-gating requests (\"lock this site to my company\", employees-only) route to the netlify-access-control skill first — Identity is the app-level user layer only.\n\n<!-- gap: getSettings() is referenced by house rules for provider discovery but its signature/return shape is not documented in the intermediate. -->\n\n<!-- system: agent-context/identity/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->\n# Netlify house rules (identity)\n\nThese are org conventions, not docs facts — merged into the rendered skill by\nctx-gen and never generated. Owned by the skills maintainer.\n\n1. Deep guides live in this skill: `references/advanced-patterns.md`\n   (SSR/session hydration) and `references/authorization-and-sessions.md`.\n2. Identity does not work under `netlify dev` — test auth flows on deploys\n   (Deploy Previews work).\n3. Identity configuration has no public API — it is dashboard-only. Never curl\n   `api.netlify.com` to flip or inspect Identity settings, never read auth\n   tokens from `~/Library/Preferences/netlify/config.json`, never probe for\n   undocumented endpoints.\n4. On failure (callback 404s, `/.netlify/identity/*` unreachable, OAuth flow\n   doesn't return), surface the error, the dashboard URL, and the setting to\n   check — then stop. Do not invent recovery commands.\n5. Never build a from-scratch third-party OAuth flow when Identity is in play —\n   no provider app registration, no `client_id`/`secret` in code, no custom\n   callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`;\n   raw OAuth beside Identity is the single most common source of rework.\n6. Server-side `getUser()`/`login()`/`admin.*` require modern v2 functions\n   (`export default`) — v1 `export { handler }` is not supported. Typed\n   Identity event handlers (`UserSignupEvent`, `event.deny()`) require\n   `@netlify/functions` ≥ 5.2.0; older installs use the legacy filenames.\n7. Don't hard-code which auth providers exist — call `getSettings()` at\n   startup and render the signup form and OAuth buttons from what it returns.\n8. Site-gating requests (\"lock this site to my company\", employees-only)\n   route to the netlify-access-control skill first — Identity is the\n   app-level user layer only.\n9. Any answer that assigns or changes roles — at signup, via `admin.*`, or in\n   the dashboard — must say the change takes effect on the user's next login\n   or token refresh, not immediately. Keep that sentence next to the code that\n   sets the role, not only in a separate JWT section: an agent answering a\n   signup question reads the signup example and stops, and it has shipped\n   answers that omit the delay.\n"
}

SHA-256 of public snapshot: c5e6e9723ca9ac756aba8fd0f8a6b09b7e89ed69c6cc7c10dfdcc8b2854e3a8e