@netlify/agent-runner-cli 1.88.0-alpha.1 → 1.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: netlify-identity
3
- description: Add user authentication with Netlify Identity. Use when implementing signups, logins, password recovery, role-based access control, OAuth providers, or protecting Netlify Functions with user tokens.
3
+ description: Add user authentication with Netlify Identity. Use when implementing signups, logins, password recovery, role-based access control, OAuth providers, protecting Netlify Functions, or server-side auth in SSR frameworks.
4
4
  ---
5
5
 
6
6
  # Netlify Identity
@@ -8,227 +8,404 @@ description: Add user authentication with Netlify Identity. Use when implementin
8
8
  Netlify Identity is a user management service for signups, logins, password recovery, user metadata, and role-based
9
9
  access control. It is built on [GoTrue](https://github.com/netlify/gotrue) and issues JSON Web Tokens (JWTs).
10
10
 
11
- There are two integration paths:
11
+ **NEVER use `netlify-identity-widget` or `gotrue-js`. Always use `@netlify/identity`.** It provides a unified, headless
12
+ TypeScript API that works in both browser and server contexts (Netlify Functions, Edge Functions, SSR frameworks). It
13
+ replaces all previous Identity client libraries.
12
14
 
13
- - **Widget** (`netlify-identity-widget`) — Drop-in UI modal for login, signup, and password recovery. Zero framework
14
- dependencies. Best when you want auth working quickly without building custom forms.
15
- - **gotrue-js** — Programmatic client for full control over authentication flows. Best when you need custom UI or
16
- headless auth logic.
15
+ ## Setup
16
+
17
+ ```bash
18
+ npm install @netlify/identity
19
+ ```
20
+
21
+ Identity is automatically enabled when the deploy includes Identity code. Default settings:
17
22
 
18
- Both communicate with the Identity endpoint at `/.netlify/identity` on your site.
23
+ - **Registration** - Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.
24
+ - **Autoconfirm** - Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip
25
+ confirmation during development.
26
+
27
+ For local development, use `netlify dev` so the Identity endpoint is available.
19
28
 
20
29
  ## Quick Start
21
30
 
22
- Add the widget CDN script and a menu element for instant auth:
31
+ Log in from the browser:
32
+
33
+ ```typescript
34
+ import { login, getUser } from '@netlify/identity'
23
35
 
24
- ```html
25
- <script type="text/javascript" src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
26
- <div data-netlify-identity-menu></div>
36
+ const user = await login('jane@example.com', 'password123')
37
+ console.log(`Hello, ${user.name}`)
38
+
39
+ // Later, check auth state
40
+ const currentUser = await getUser()
27
41
  ```
28
42
 
29
- Handle logins:
43
+ Protect a Netlify Function:
30
44
 
31
- ```javascript
32
- netlifyIdentity.on('login', async (user) => {
33
- const jwt = await netlifyIdentity.refresh()
34
- await fetch('/.netlify/functions/protected', {
35
- headers: { Authorization: `Bearer ${jwt}` },
36
- })
37
- })
45
+ ```typescript
46
+ // netlify/functions/protected.mts
47
+ import { getUser } from '@netlify/identity'
48
+ import type { Context } from '@netlify/functions'
49
+
50
+ export default async (req: Request, context: Context) => {
51
+ const user = await getUser()
52
+ if (!user) return new Response('Unauthorized', { status: 401 })
53
+ return Response.json({ id: user.id, email: user.email })
54
+ }
38
55
  ```
39
56
 
40
- ### When to Use Widget vs gotrue-js
57
+ ## Error Handling
41
58
 
42
- - **Prefer the widget** when the user wants auth working quickly, doesn't have custom UI requirements, or is building a
43
- simple site. It requires minimal code and automatically sets the `nf_jwt` cookie needed for role-based redirects.
44
- - **Use gotrue-js** when the user needs custom login/signup forms, headless auth, or programmatic control over the auth
45
- flow. Pass `setCookie: true` if you need role-based redirects (see below).
46
- - **Never mix both** in the same page unless accessing `netlifyIdentity.gotrue` for low-level operations while using the
47
- widget for UI.
59
+ `@netlify/identity` throws two error classes:
48
60
 
49
- ## Setup
61
+ - **`AuthError`** - Thrown by auth operations (login, signup, logout, etc.). Has `message`, optional `status` (HTTP
62
+ status code from GoTrue), and optional `cause` (original error).
63
+ - **`MissingIdentityError`** - Thrown when Identity is not configured in the current environment (site doesn't have
64
+ Identity enabled, or not running via `netlify dev`).
50
65
 
51
- Identity is automatically enabled when the deploy includes Identity code. Default settings:
66
+ `getUser()` and `isAuthenticated()` never throw - they return `null` and `false` respectively on failure.
52
67
 
53
- - **Registration** — Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.
54
- - **Autoconfirm** — Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip confirmation during development.
68
+ ### Try/Catch Pattern
55
69
 
56
- ## Widget (Drop-in UI)
70
+ ```typescript
71
+ import { login, AuthError, MissingIdentityError } from '@netlify/identity'
72
+
73
+ try {
74
+ const user = await login(email, password)
75
+ } catch (error) {
76
+ if (error instanceof MissingIdentityError) {
77
+ // Identity not configured - show setup instructions
78
+ showError('Identity is not enabled on this site.')
79
+ return
80
+ }
81
+ if (error instanceof AuthError) {
82
+ switch (error.status) {
83
+ case 401:
84
+ showError('Invalid email or password.')
85
+ break
86
+ case 403:
87
+ showError('Signups are not allowed for this site.')
88
+ break
89
+ case 422:
90
+ showError('Invalid input. Check your email and password.')
91
+ break
92
+ case 404:
93
+ showError('User not found.')
94
+ break
95
+ default:
96
+ showError(error.message)
97
+ }
98
+ return
99
+ }
100
+ throw error
101
+ }
102
+ ```
57
103
 
58
- ### CDN Script Tag
104
+ ### Common Status Codes
59
105
 
60
- Include the widget script and add HTML attributes for automatic controls:
106
+ | Status | Meaning |
107
+ |--------|---------|
108
+ | 401 | Invalid credentials or expired token |
109
+ | 403 | Action not allowed (e.g., signups disabled) |
110
+ | 422 | Validation error (e.g., weak password, malformed email) |
111
+ | 404 | User or resource not found |
61
112
 
62
- ```html
63
- <script type="text/javascript" src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
113
+ ## Authentication Flows
64
114
 
65
- <!-- Login/Signup menu (shows username + logout when logged in) -->
66
- <div data-netlify-identity-menu></div>
115
+ ### Login
67
116
 
68
- <!-- Simple button that opens the modal -->
69
- <div data-netlify-identity-button>Login with Netlify Identity</div>
117
+ ```typescript
118
+ import { login, AuthError } from '@netlify/identity'
119
+
120
+ let loading = false
121
+
122
+ async function handleLogin(email: string, password: string) {
123
+ loading = true
124
+ try {
125
+ const user = await login(email, password)
126
+ showSuccess(`Welcome back, ${user.name ?? user.email}`)
127
+ } catch (error) {
128
+ if (error instanceof AuthError) {
129
+ showError(error.status === 401 ? 'Invalid email or password.' : error.message)
130
+ }
131
+ } finally {
132
+ loading = false
133
+ }
134
+ }
70
135
  ```
71
136
 
72
- The widget attaches itself to `window.netlifyIdentity` automatically.
137
+ ### Signup
73
138
 
74
- ### npm Module
139
+ After signup, check `user.emailVerified` to determine if the user was auto-confirmed (logged in immediately) or needs
140
+ to confirm their email first.
75
141
 
76
- ```bash
77
- npm install netlify-identity-widget
142
+ ```typescript
143
+ import { signup, AuthError } from '@netlify/identity'
144
+
145
+ async function handleSignup(email: string, password: string, name: string) {
146
+ try {
147
+ const user = await signup(email, password, { full_name: name })
148
+ if (user.emailVerified) {
149
+ // Autoconfirm is ON - user is logged in
150
+ showSuccess('Account created. You are now logged in.')
151
+ } else {
152
+ // Autoconfirm is OFF - confirmation email sent, user is NOT logged in
153
+ showSuccess('Check your email to confirm your account.')
154
+ }
155
+ } catch (error) {
156
+ if (error instanceof AuthError) {
157
+ showError(error.status === 403 ? 'Signups are not allowed.' : error.message)
158
+ }
159
+ }
160
+ }
78
161
  ```
79
162
 
80
- ```javascript
81
- import netlifyIdentity from 'netlify-identity-widget'
163
+ When autoconfirm is off, the confirmation email contains a link that redirects the user back to the site with
164
+ `#confirmation_token=<token>` in the URL hash. `handleAuthCallback()` processes this automatically - it calls
165
+ `confirmEmail()` under the hood, logs the user in, and returns `{ type: 'confirmation', user }`. This is why
166
+ `handleAuthCallback()` must be called on page load (see the OAuth section below for the full switch).
82
167
 
83
- netlifyIdentity.init({
84
- container: '#netlify-modal', // defaults to document.body
85
- locale: 'en', // language code (en, fr, es, pt, de, etc.)
86
- // APIUrl: 'https://your-site.netlify.app/.netlify/identity', // only for non-Netlify hosts
87
- })
88
- ```
89
-
90
- Set `APIUrl` only when the app is served from a different domain than the Identity endpoint (Cordova, Electron, or
91
- custom domains).
168
+ ### OAuth
92
169
 
93
- ### Events
170
+ OAuth is a two-step flow: `oauthLogin(provider)` redirects away from the site, then `handleAuthCallback()` processes
171
+ the redirect when the user returns.
94
172
 
95
- ```javascript
96
- netlifyIdentity.on('init', (user) => console.log('init', user))
97
- netlifyIdentity.on('login', (user) => console.log('login', user))
98
- netlifyIdentity.on('logout', () => console.log('Logged out'))
99
- netlifyIdentity.on('error', (err) => console.error('Error', err))
173
+ ```typescript
174
+ import { oauthLogin } from '@netlify/identity'
100
175
 
101
- netlifyIdentity.off('login') // unbind all login handlers
102
- netlifyIdentity.off('login', handler) // unbind a specific handler
176
+ // Step 1: Redirect to OAuth provider (this navigates away - never returns)
177
+ function handleOAuthClick(provider: 'google' | 'github' | 'gitlab' | 'bitbucket') {
178
+ oauthLogin(provider)
179
+ }
103
180
  ```
104
181
 
105
- ### Key Methods
106
-
107
- ```javascript
108
- netlifyIdentity.open() // open modal (default tab)
109
- netlifyIdentity.open('login') // open to login tab
110
- netlifyIdentity.open('signup') // open to signup tab
111
- netlifyIdentity.open('signup', { full_name: 'Jane' }) // pre-fill signup metadata
112
- netlifyIdentity.close()
113
- netlifyIdentity.logout()
114
- const user = netlifyIdentity.currentUser() // null if not logged in
115
- const jwt = await netlifyIdentity.refresh() // refresh the JWT
116
- const gotrueClient = netlifyIdentity.gotrue // underlying GoTrue client
182
+ ```typescript
183
+ import { handleAuthCallback, AuthError } from '@netlify/identity'
184
+
185
+ // Step 2: Process the redirect on page load
186
+ async function processCallback() {
187
+ try {
188
+ const result = await handleAuthCallback()
189
+ if (!result) return // No callback hash present - normal page load
190
+
191
+ switch (result.type) {
192
+ case 'oauth':
193
+ showSuccess(`Logged in as ${result.user?.email}`)
194
+ break
195
+ case 'confirmation':
196
+ showSuccess('Email confirmed. You are now logged in.')
197
+ break
198
+ case 'recovery':
199
+ // User is authenticated but must set a new password
200
+ showPasswordResetForm(result.user)
201
+ break
202
+ case 'invite':
203
+ // User must set a password to accept the invite
204
+ showInviteAcceptForm(result.token)
205
+ break
206
+ case 'email_change':
207
+ showSuccess('Email address updated.')
208
+ break
209
+ }
210
+ } catch (error) {
211
+ if (error instanceof AuthError) {
212
+ showError(error.message)
213
+ }
214
+ }
215
+ }
117
216
  ```
118
217
 
119
- ## gotrue-js (Programmatic Client)
120
-
121
- ### Install
218
+ Always call `handleAuthCallback()` on page load in any app that uses OAuth, password recovery, invites, or email
219
+ confirmation. It handles all callback types via the URL hash.
122
220
 
123
- ```bash
124
- npm install gotrue-js
125
- ```
221
+ ### Password Recovery
126
222
 
127
- ### Initialize
223
+ Three-step flow: request recovery email, handle the callback, then set a new password.
128
224
 
129
225
  ```typescript
130
- import GoTrue from 'gotrue-js'
226
+ import { requestPasswordRecovery, handleAuthCallback, updateUser, AuthError } from '@netlify/identity'
227
+
228
+ // Step 1: Send recovery email
229
+ async function handleForgotPassword(email: string) {
230
+ try {
231
+ await requestPasswordRecovery(email)
232
+ showSuccess('Check your email for a password reset link.')
233
+ } catch (error) {
234
+ if (error instanceof AuthError) showError(error.message)
235
+ }
236
+ }
131
237
 
132
- const auth = new GoTrue({
133
- APIUrl: 'https://<your-site>.netlify.app/.netlify/identity',
134
- setCookie: false,
135
- })
238
+ // Step 2: handleAuthCallback() returns { type: 'recovery', user } - show password reset form
239
+ // (See the handleAuthCallback switch above)
240
+
241
+ // Step 3: Set new password
242
+ async function handlePasswordReset(newPassword: string) {
243
+ try {
244
+ await updateUser({ password: newPassword })
245
+ showSuccess('Password updated.')
246
+ } catch (error) {
247
+ if (error instanceof AuthError) showError(error.message)
248
+ }
249
+ }
136
250
  ```
137
251
 
138
- `setCookie: true` tells the server to set HttpOnly cookies, which protects tokens from XSS but is required if you need
139
- role-based redirects without the widget (the CDN reads an `nf_jwt` cookie to evaluate redirect conditions). Use `false`
140
- when you manage tokens entirely in JavaScript and don't need CDN-level access control.
252
+ Note: The recovery callback fires a `'recovery'` auth event, not `'login'`. The user is authenticated but should be
253
+ prompted to set a new password before navigating away.
141
254
 
142
- ### Core Methods
255
+ ### Invite Acceptance
143
256
 
144
- ```javascript
145
- // Signup (sends confirmation email if autoconfirm is off)
146
- const response = await auth.signup(email, password)
147
- // Pass user_metadata during signup: auth.signup(email, password, { full_name: 'Jane' })
257
+ When a user clicks an invite link, `handleAuthCallback()` returns `{ type: 'invite', user: null, token }`. Use the
258
+ token to accept the invite and set a password.
148
259
 
149
- // Confirm email (token from confirmation email URL fragment)
150
- const user = await auth.confirm(token, true) // remember = persist session
260
+ ```typescript
261
+ import { acceptInvite, AuthError } from '@netlify/identity'
262
+
263
+ async function handleAcceptInvite(token: string, password: string) {
264
+ try {
265
+ const user = await acceptInvite(token, password)
266
+ showSuccess(`Welcome, ${user.email}! Your account is ready.`)
267
+ } catch (error) {
268
+ if (error instanceof AuthError) showError(error.message)
269
+ }
270
+ }
271
+ ```
151
272
 
152
- // Login
153
- const user = await auth.login(email, password, true) // remember = persist session
273
+ ### Email Change
154
274
 
155
- // Current user (from localStorage)
156
- const user = auth.currentUser()
275
+ When a user verifies an email change, `handleAuthCallback()` returns `{ type: 'email_change', user }`. This requires an
276
+ active browser session - the user must be logged in when clicking the verification link.
157
277
 
158
- // Get fresh JWT (auto-refreshes if expired; pass true to force)
159
- const token = await user.jwt()
278
+ ```typescript
279
+ import { verifyEmailChange, AuthError } from '@netlify/identity'
280
+
281
+ // If you need to verify programmatically with a token:
282
+ async function handleEmailChangeVerification(token: string) {
283
+ try {
284
+ const user = await verifyEmailChange(token)
285
+ showSuccess(`Email updated to ${user.email}`)
286
+ } catch (error) {
287
+ if (error instanceof AuthError) showError(error.message)
288
+ }
289
+ }
290
+ ```
160
291
 
161
- // Update user
162
- await user.update({ email: 'new@example.com', password: 'newpassword' })
163
- await user.update({ data: { full_name: 'Jane Doe' } }) // update user_metadata
292
+ ### Session Hydration
164
293
 
165
- // Logout
166
- await user.logout()
294
+ `hydrateSession()` bridges server-set cookies to the browser session. Call it on page load when using server-side login
295
+ (e.g., login inside a Netlify Function followed by a redirect).
167
296
 
168
- // Password recovery
169
- await auth.requestPasswordRecovery(email) // sends recovery email
170
- const user = await auth.recover(recoveryToken, true) // remember = persist session
297
+ ```typescript
298
+ import { hydrateSession } from '@netlify/identity'
171
299
 
172
- // Check server settings (signup enabled? autoconfirm? OAuth providers?)
173
- const settings = await auth.settings()
300
+ // On page load
301
+ const user = await hydrateSession()
302
+ if (user) {
303
+ // Browser session is now in sync with server-set cookies
304
+ }
174
305
  ```
175
306
 
176
- ### Error Handling
307
+ Note: `getUser()` auto-hydrates from the `nf_jwt` cookie if no browser session exists, so explicit `hydrateSession()`
308
+ is only needed when you want to restore the full session (including token refresh timers) after a server-side login.
177
309
 
178
- gotrue-js exports `JSONHTTPError`, `TextHTTPError`, and `HTTPError` for granular error catching. Use `error.json`,
179
- `error.data`, or `error.status` respectively.
310
+ ## Auth Events
180
311
 
181
- ## Token Handling
312
+ Subscribe to auth state changes with `onAuthChange`. Returns an unsubscribe function. No-op on server.
182
313
 
183
- - Access tokens expire after **1 hour**
184
- - Both `user.jwt()` (gotrue-js) and `netlifyIdentity.refresh()` (widget) auto-refresh using the refresh token
185
- - Always call `jwt()` or `refresh()` before authenticated requests — do not cache tokens
186
- - Send the token as `Authorization: Bearer <token>`
314
+ ```typescript
315
+ import { onAuthChange, AUTH_EVENTS } from '@netlify/identity'
316
+
317
+ const unsubscribe = onAuthChange((event, user) => {
318
+ switch (event) {
319
+ case AUTH_EVENTS.LOGIN:
320
+ console.log('User logged in:', user?.email)
321
+ break
322
+ case AUTH_EVENTS.LOGOUT:
323
+ console.log('User logged out')
324
+ break
325
+ case AUTH_EVENTS.TOKEN_REFRESH:
326
+ // Token auto-refreshed in background
327
+ break
328
+ case AUTH_EVENTS.USER_UPDATED:
329
+ console.log('User profile updated:', user?.email)
330
+ break
331
+ case AUTH_EVENTS.RECOVERY:
332
+ // Recovery token processed - prompt for new password
333
+ console.log('Password recovery initiated')
334
+ break
335
+ }
336
+ })
187
337
 
188
- ## Roles and Authorization
338
+ // Later: unsubscribe()
339
+ ```
189
340
 
190
- ### Metadata Types
341
+ Auth events are automatically detected across browser tabs via the storage event listener - no extra setup needed.
191
342
 
192
- - **`app_metadata.roles`** — Server-controlled. Only settable via the Netlify UI, admin API, or server-side code
193
- (Identity event functions, protected functions with admin token). Do not allow users to set their own roles.
194
- - **`user_metadata`** — User-controlled. Users can update this via `user.update({ data: { ... } })`.
343
+ ## Settings-Driven UI
195
344
 
196
- ### Role-Based Redirects
345
+ Fetch the project's Identity settings to conditionally render signup forms and OAuth buttons.
197
346
 
198
- Use `netlify.toml` to restrict paths by role:
347
+ ```typescript
348
+ import { getSettings } from '@netlify/identity'
199
349
 
200
- ```toml
201
- [[redirects]]
202
- from = "/admin/*"
203
- to = "/admin/:splat"
204
- status = 200
205
- conditions = { Role = ["admin"] }
350
+ const settings = await getSettings()
351
+ // settings.autoconfirm - boolean, whether email confirmation is skipped
352
+ // settings.disableSignup - boolean, whether registration is closed
353
+ // settings.providers - Record<AuthProvider, boolean>, e.g. { google: true, github: true, ... }
206
354
 
207
- [[redirects]]
208
- from = "/admin/*"
209
- to = "/"
210
- status = 302
355
+ // Conditionally render signup
356
+ if (!settings.disableSignup) {
357
+ showSignupForm()
358
+ }
359
+
360
+ // Conditionally render OAuth buttons
361
+ for (const [provider, enabled] of Object.entries(settings.providers)) {
362
+ if (enabled) {
363
+ showOAuthButton(provider)
364
+ }
365
+ }
211
366
  ```
212
367
 
213
- Rules are evaluated top-to-bottom. The first redirect matches users with the `admin` role; everyone else falls through
214
- to the second rule and is redirected away.
368
+ ## Full API Reference
369
+
370
+ For the complete API reference - all function signatures, type definitions, OAuth helpers, admin operations, session
371
+ management, auth events, and framework-specific integration examples - read the package README:
372
+
373
+ ```
374
+ node_modules/@netlify/identity/README.md
375
+ ```
376
+
377
+ The README is shipped with the npm package and is always in sync with the installed version.
378
+
379
+ ## SSR Integration Patterns
215
380
 
216
- **How it works:** The widget sets an `nf_jwt` cookie that the CDN reads to evaluate `conditions = { Role = [...] }`.
217
- Without this cookie, role-based redirects will not work. If using gotrue-js instead of the widget, pass
218
- `setCookie: true` when initializing GoTrue so the server sets this cookie.
381
+ For SSR frameworks, the recommended pattern is:
382
+
383
+ - **Browser-side** for auth mutations: `login()`, `signup()`, `logout()`, `oauthLogin()`
384
+ - **Server-side** for reading auth state: `getUser()`, `getSettings()`, `getIdentityConfig()`
385
+
386
+ Browser-side auth mutations set the `nf_jwt` cookie and localStorage, and emit `onAuthChange` events. The
387
+ server reads the cookie on the next request.
388
+
389
+ The library also supports server-side mutations (`login()`, `signup()`, `logout()` inside Netlify Functions), but these
390
+ require the Netlify Functions runtime to set cookies. After a server-side mutation, use a full page navigation so the
391
+ browser sends the new cookie.
392
+
393
+ Always use `window.location.href` (not framework router navigation) after server-side auth mutations in Next.js,
394
+ TanStack Start, and SvelteKit. Remix `redirect()` is safe because Remix actions return real HTTP responses.
219
395
 
220
396
  ## Identity Event Functions
221
397
 
222
398
  Special serverless functions that trigger on Identity lifecycle events. These use the **legacy named `handler` export**
223
399
  (not the modern default export) because they receive `event.body` containing the user payload.
224
400
 
225
- **Event names:** `identity-validate`, `identity-signup`, `identity-login`
401
+ Always use the legacy named `handler` export (not default export) for Identity event functions. The filename must match
402
+ the event name exactly (e.g., `netlify/functions/identity-signup.mts`).
226
403
 
227
- - `identity-signup` — fires when a new user signs up (email/password or OAuth)
228
- - `identity-login` — fires on each login
229
- - `identity-validate` — fires during signup before the user is created; return a non-200 status to reject the signup
404
+ **Event names:** `identity-validate`, `identity-signup`, `identity-login`
230
405
 
231
- The filename must match the event name exactly (e.g. `netlify/functions/identity-signup.mts`).
406
+ - `identity-signup` - fires when a new user signs up (email/password or OAuth)
407
+ - `identity-login` - fires on each login
408
+ - `identity-validate` - fires during signup before the user is created; return a non-200 status to reject
232
409
 
233
410
  ### Example: Assign Default Role on Signup
234
411
 
@@ -253,77 +430,183 @@ const handler: Handler = async (event: HandlerEvent, context: HandlerContext) =>
253
430
  export { handler }
254
431
  ```
255
432
 
256
- The response body replaces `app_metadata` and/or `user_metadata` on the user record include all fields you want to
433
+ The response body replaces `app_metadata` and/or `user_metadata` on the user record - include all fields you want to
257
434
  keep, not just new ones.
258
435
 
259
- ## Protected Functions
436
+ For bulk user management or role changes outside lifecycle events, use the `admin` API instead of Identity event
437
+ functions.
438
+
439
+ ## Roles and Authorization
440
+
441
+ ### Metadata Types
442
+
443
+ - **`app_metadata.roles`** - Server-controlled. Only settable via the Netlify UI, admin API, or Identity event functions.
444
+ Do not allow users to set their own roles.
445
+ - **`user_metadata`** - User-controlled. Users can update this via `updateUser({ data: { ... } })`.
260
446
 
261
- Functions that verify the calling user's identity. Use the legacy `handler` export to access `context.clientContext`.
447
+ ### Role-Based Redirects
448
+
449
+ Use `netlify.toml` to restrict paths by role:
450
+
451
+ ```toml
452
+ [[redirects]]
453
+ from = "/admin/*"
454
+ to = "/admin/:splat"
455
+ status = 200
456
+ conditions = { Role = ["admin"] }
457
+
458
+ [[redirects]]
459
+ from = "/admin/*"
460
+ to = "/"
461
+ status = 302
462
+ ```
463
+
464
+ Rules are evaluated top-to-bottom. The first redirect matches users with the `admin` role; everyone else falls through
465
+ to the second rule and is redirected away.
466
+
467
+ **How it works:** The `nf_jwt` cookie is read by the CDN to evaluate `conditions = { Role = [...] }`. Without this
468
+ cookie, role-based redirects will not work.
469
+
470
+ ## Common Errors & Solutions
471
+
472
+ ### "Signups not allowed for this instance" (403)
473
+
474
+ **Cause:** Registration is set to Invite only.
475
+
476
+ **Fix:**
477
+
478
+ 1. Change to Open in **Project configuration > Identity**
479
+ 2. Or invite users from the Identity tab in the Netlify UI
262
480
 
263
481
  ```typescript
264
- // netlify/functions/protected-action.mts
265
- import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions'
482
+ if (error instanceof AuthError && error.status === 403) {
483
+ showError('Signups are disabled. Contact the site admin for an invite.')
484
+ }
485
+ ```
266
486
 
267
- const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
268
- const { identity, user } = context.clientContext || {}
487
+ ### Invalid credentials (401)
269
488
 
270
- if (!user) {
271
- return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
272
- }
489
+ **Cause:** Wrong email or password on login.
490
+
491
+ **Fix:** Show a user-facing error and let the user retry. Do not reveal whether the email or password was wrong.
273
492
 
274
- // user.sub — user ID
275
- // user.email email address
276
- // user.app_metadata.roles assigned roles
277
- // user.user_metadata — user-controlled data
278
-
279
- // Use identity.token (admin token) to call the Identity admin API:
280
- const adminAuthHeader = `Bearer ${identity.token}`
281
- const response = await fetch(`${identity.url}/admin/users/${user.sub}`, {
282
- method: 'PUT',
283
- headers: { Authorization: adminAuthHeader },
284
- body: JSON.stringify({ app_metadata: { roles: ['editor'] } }),
285
- })
286
-
287
- const data = await response.json()
288
- return { statusCode: 200, body: JSON.stringify(data) }
493
+ ```typescript
494
+ if (error instanceof AuthError && error.status === 401) {
495
+ showError('Invalid email or password.')
289
496
  }
497
+ ```
290
498
 
291
- export { handler }
499
+ ### "Email not confirmed"
500
+
501
+ **Cause:** User tries to log in before confirming their email (autoconfirm is off).
502
+
503
+ **Fix:** Tell the user to check their inbox. Optionally provide a way to resend the confirmation email via `signup()`
504
+ with the same credentials.
505
+
506
+ ### "Token expired" / 401 on API calls
507
+
508
+ **Cause:** Stale access token.
509
+
510
+ **Fix:**
511
+
512
+ 1. Always use `getUser()` before authenticated requests - it auto-hydrates from cookies
513
+ 2. In the browser, the library auto-refreshes tokens via `startTokenRefresh()`
514
+ 3. On the server, call `refreshSession()` in middleware to handle near-expiry tokens
515
+
516
+ ```typescript
517
+ const newToken = await refreshSession()
518
+ if (newToken) {
519
+ // Token was refreshed - retry the request
520
+ }
292
521
  ```
293
522
 
294
- **Note:** Operations using `identity.token` (admin token) do **not** work locally with `netlify dev`. Deploy to Netlify
295
- to test server-side admin operations.
523
+ ### Identity event function not triggering
524
+
525
+ **Cause:** Filename or export format does not match expected convention.
296
526
 
297
- ## External OAuth Providers
527
+ **Fix:**
298
528
 
299
- Netlify Identity supports Google, GitHub, GitLab, and BitBucket as built-in OAuth providers.
529
+ 1. Verify filename matches exactly: `identity-signup`, `identity-validate`, or `identity-login`
530
+ 2. Place in `netlify/functions/` with `.mts` or `.mjs` extension
531
+ 3. Use named `handler` export (not default export)
300
532
 
301
- **Enable in Netlify UI:** Project configuration > Identity > Registration > External providers
533
+ ### `MissingIdentityError`
302
534
 
303
- ### Widget
535
+ **Cause:** Identity is not configured in the current environment.
304
536
 
305
- Once enabled in the UI, the widget automatically shows OAuth provider buttons. No additional code needed.
537
+ **Fix:**
306
538
 
307
- ### gotrue-js
539
+ 1. Ensure Identity is enabled on the project
540
+ 2. Use `netlify dev` for local development so the Identity endpoint is available
308
541
 
309
- ```javascript
310
- const url = auth.loginExternalUrl('github')
311
- // Redirect the user to this URL to start the OAuth flow
312
- window.location.href = url
542
+ ```typescript
543
+ if (error instanceof MissingIdentityError) {
544
+ showError('Identity is not enabled. Run "netlify dev" or enable Identity in project settings.')
545
+ }
313
546
  ```
314
547
 
315
- Available provider names: `'google'`, `'github'`, `'gitlab'`, `'bitbucket'`
548
+ ### `AuthError` on server - missing Netlify runtime
316
549
 
317
- ## Common Errors & Solutions
550
+ **Cause:** Server-side `login()`, `signup()`, or `logout()` require the Netlify Functions runtime to set cookies.
551
+
552
+ **Fix:**
553
+
554
+ 1. Deploy to Netlify to use server-side auth mutations
555
+ 2. Or use `netlify dev` for local development
556
+
557
+ ### "User not found" after OAuth login
558
+
559
+ **Cause:** OAuth provider is not enabled for the project.
560
+
561
+ **Fix:**
562
+
563
+ 1. Enable the provider in **Project configuration > Identity > External providers**
564
+ 2. Users are created automatically on first OAuth login
565
+
566
+ ### Account operations fail after server-side login
567
+
568
+ **Cause:** Browser-side session is not bootstrapped from server-set cookies.
569
+
570
+ **Fix:** Call `hydrateSession()` on page load to bridge server-set cookies to the browser session. Then use
571
+ `updateUser()`, `verifyEmailChange()`, or other account operations.
572
+
573
+ ```typescript
574
+ import { hydrateSession, updateUser } from '@netlify/identity'
575
+
576
+ // On page load after server-side login
577
+ await hydrateSession()
578
+
579
+ // Now account operations work
580
+ await updateUser({ data: { full_name: 'Jane' } })
581
+ ```
582
+
583
+ ### "Email change verification requires an active browser session"
584
+
585
+ **Cause:** `verifyEmailChange()` was called without an active session. The user must be logged in when clicking the
586
+ email change verification link.
587
+
588
+ **Fix:** Ensure the user is logged in before processing the `email_change` callback. If the session expired, prompt
589
+ the user to log in first.
590
+
591
+ ### "No user is currently logged in"
592
+
593
+ **Cause:** An account operation (`updateUser`, `verifyEmailChange`) was called without an authenticated user.
594
+
595
+ **Fix:** Check `getUser()` before calling account operations. If `null`, redirect to login.
596
+
597
+ ```typescript
598
+ const user = await getUser()
599
+ if (!user) {
600
+ redirectToLogin()
601
+ return
602
+ }
603
+ await updateUser({ data: { full_name: 'Jane' } })
604
+ ```
605
+
606
+ ### Stale session - user deleted server-side
607
+
608
+ **Cause:** `getUser()` returns `null` when the `nf_jwt` cookie is gone but localStorage still has a stale
609
+ session. This happens when a user is deleted via the admin API or Netlify UI.
318
610
 
319
- - **"Signups not allowed for this instance" (403)** Registration is set to Invite only. Change to Open in Project
320
- configuration > Identity, or invite users from the Identity tab.
321
- - **"Token expired" / 401 on API calls** — Stale access token. Always call `user.jwt()` or `netlifyIdentity.refresh()`
322
- before authenticated requests.
323
- - **Identity event function not triggering** — Verify filename matches exactly (`identity-signup`, `identity-validate`,
324
- or `identity-login`), is in `netlify/functions/`, and uses `.mts`/`.mjs`.
325
- - **`clientContext` is undefined** — Use named `handler` export (not `export default`), send `Authorization: Bearer`
326
- header, and verify token is fresh.
327
- - **Admin methods not working locally** — `identity.token` is unavailable in local dev. Deploy to Netlify to test.
328
- - **"User not found" after OAuth login** — Enable the OAuth provider in Project configuration > Identity > External
329
- providers. Users are created on first OAuth login.
611
+ **Fix:** `getUser()` handles this gracefully - it returns `null` and the stale localStorage entry is ignored. Always
612
+ check for `null` before using the user object.