How to add user login to a Netlify site with Identity

Netlify Identity handles signup, login, OAuth social login, and role-based access. Setup, the server-side API, and the five footguns including why auth won't work locally.

Tutorial

Key takeaways

Netlify Identity is available and actively supported. Enable it in the dashboard under Project configuration > Identity, install @netlify/identity, and you get signup, login, OAuth social login, server-side user verification, and role-based access control. Your visitors don’t need Netlify accounts. Two things to know before you start: Identity does not work under netlify dev, so test on a Deploy Preview, and calling handleAuthCallback() on your landing page is mandatory or confirmation links never complete.

  • Use @netlify/identity. The older netlify-identity-widget and gotrue-js are superseded.
  • handleAuthCallback() is not optional. It processes OAuth redirects, email confirmations, password recovery, and invites.
  • Don’t hand-roll OAuth beside Identity. Use oauthLogin() and handleAuthCallback(). Raw OAuth alongside Identity is the most common source of rework.
  • Guard server-side login/signup/logout with verifyRequestOrigin(req). Without it an attacker can log a victim into the attacker’s account.
  • RBAC redirects need a fallback rule, or a visitor without the role gets a bare 404 and no way to log in.
  • admin.* runs in Netlify Functions only. Not the browser, not Edge Functions.

Identity is not deprecated

Worth addressing directly, because it’s the most common piece of misinformation about this product: Netlify Identity was announced as discontinued, and then that decision was reversed after community pushback. It’s supported, it’s on all credit-based plans at no extra cost, and it has a current package. If an AI assistant told you Identity is going away and you should use a third-party auth provider, that answer is stale.

What Identity gives you is an app-level user layer: your visitors sign up, log in, get roles, and you check those roles server-side or at the CDN edge. It’s the layer between “anyone can see this site” and “this specific user owns this data.”

When to use Identity, and when not to

Identity is for app users. Someone signs up for your product, logs in, and sees their own data.

It is not for locking a whole site to your company. “Employees only,” “password-protect this site,” “restrict previews to my team” are a different job, handled by site-level access control. Start there instead: How to password-protect a Netlify site or deploy previews. If you’re unsure which of the three kinds of Netlify auth you need, Netlify auth: site protection, app login, or team SSO? sorts them out.

Two more boundaries. External JWT providers are Enterprise only, and you can use Identity or an external JWT provider, not both. You cannot authenticate third-party JWT tokens while Identity is enabled. And HTTPS is required, so on a custom domain get SSL working before you integrate.

Footguns, before you write code

Identity does not work under netlify dev. Local dev cannot exercise /.netlify/identity/*. Test auth flows on a deploy. Deploy Previews work fine.

Never build a from-scratch third-party OAuth flow beside Identity. No provider app registration in code, no client_id or secret in code, no custom callback token exchange. Use oauthLogin() plus handleAuthCallback().

Identity configuration is dashboard-only. There’s no public API for it. Don’t try to flip or inspect settings through api.netlify.com.

RBAC redirects without a fallback produce a raw 404. A visitor lacking the role gets a bare 404 page with no login prompt. Always add the fallback rule.

Server-side login(), signup(), and logout() need CSRF protection. Call verifyRequestOrigin(req) first.

Worked example

Enable Identity in the dashboard first: Project configuration > IdentityEnable Identity. Then:

npm install @netlify/identity

Client and universal auth

import { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'
// Sign up — sends a confirmation email by default (skippable via autoconfirm setting)
const user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })
// Log in / log out
await login('jane@example.com', 'securepassword')
await logout()
// Current user — null if not logged in (works in browser + server)
const u = await getUser()
if (u) console.log(u.email)
// OAuth — redirects browser to provider login
oauthLogin('github') // 'google' | 'github' | 'gitlab' | 'bitbucket'

The callback handler you must not skip

import { handleAuthCallback } from '@netlify/identity'
const result = await handleAuthCallback()
if (result) console.log(result.type, result.user.email) // may be falsy if nothing to process

Call this on your landing page. It processes every token type that arrives in the URL hash: OAuth redirect, email confirmation, password recovery, and invite. Without it, confirmation links and OAuth redirects never finish and users are left staring at a page that did nothing.

Related client functions: recoverPassword() completes a password reset and acceptInvite() completes invite acceptance, both as alternatives to letting handleAuthCallback() handle those tokens. refreshSession() refreshes the token so newly-assigned roles take effect.

Rather than hard-coding which providers you support, call getSettings() at startup and render your signup form and OAuth buttons from what it returns. That way enabling a provider in the dashboard doesn’t require a code change.

Server-side verification

Handlers must be modern v2 functions using export default. The v1 export { handler } shape is not supported for getUser(), login(), or admin.*.

import { getUser } from '@netlify/identity'
import type { Context } from '@netlify/functions' // or '@netlify/edge-functions' for Edge
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 })
return Response.json({ id: user.id, email: user.email })
}

getUser() works in the browser, in Netlify Functions, and in Edge Functions.

CSRF protection on exposed auth endpoints

netlify/functions/login.ts
import { login, verifyRequestOrigin } from '@netlify/identity'
import type { Context } from '@netlify/functions'
export default async (req: Request, context: Context) => {
verifyRequestOrigin(req) // throws 403 on Origin mismatch; supports { allowedOrigins }
const { email, password } = await req.json()
await login(email, password)
return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
}

Skipping verifyRequestOrigin on an endpoint that calls login() means an attacker can cause a victim’s browser to log into the attacker’s account, then watch what the victim does there. It’s a real attack, not a theoretical one.

Admin operations

admin.* uses a short-lived admin token and runs only in Netlify Functions. Not the browser, not Edge Functions.

import { admin } from '@netlify/identity'
import type { Context } from '@netlify/functions'
export default async (req: Request, context: Context) => {
const users = await admin.listUsers() // array of users
return Response.json({ total: users.length })
}

admin.listUsers() returns an array of users. admin.updateUser() updates one, which is how you change roles for an existing user.

Assigning roles at signup

Identity event functions are invoked by the platform; you don’t call them. Typed handlers need @netlify/functions 5.2.0 or newer.

netlify/functions/identity.mts
import type { UserSignupEvent } from "@netlify/functions"
export default {
userSignup(event: UserSignupEvent) {
return {
user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: ["member"] } },
}
},
}

Note the payload fields are camelCase here (appMetadata, userMetadata, confirmedAt), even though the stored user object uses app_metadata.roles.

To block a signup:

netlify/functions/identity.mts
import type { UserValidateEvent } from "@netlify/functions"
export default {
userValidate(event: UserValidateEvent) {
if (!event.user.email?.endsWith("@example.com")) return event.deny()
},
}

For work that shouldn’t hold up the user, run the handler in background mode:

netlify/functions/identity.mts
import type { Config, UserLoginEvent } from "@netlify/functions"
export default { userLogin(event: UserLoginEvent) { /* async tracking */ } }
export const config: Config = { background: true }

Role-based access control

RBAC is enforced at the CDN edge with no origin round trip. Add a Role parameter to your redirect rules.

# _redirects — ALWAYS include a fallback or non-admins get a raw 404
/admin/* /admin/:splat 200! Role=admin
/admin/* /login 401!
# multiple roles, comma-chained
/private/* /private/:splat 200! Role=editor,admin
netlify.toml
[[redirects]]
from = "/admin/*"
to = "/admin/:splat"
force = true
status = 200
conditions = {Role = ["editor", "admin"]}

The second line in the _redirects example is the fallback, and it’s the part people leave out. Without it, a logged-out visitor hitting /admin/anything gets a bare 404 with no indication that logging in would help.

Common failure modes

Auth does nothing locally. Identity doesn’t work under netlify dev. Push to a Deploy Preview and test there.

Your confirmation emails lead to a page that does nothing. You’re missing handleAuthCallback() on the landing page. Same cause for OAuth redirects that never complete.

You assigned a role and the user still can’t get in. Role changes take effect on next login or token refresh. They don’t invalidate the current JWT. Have the client call refreshSession(), or wait for the next login.

Non-admins get a blank 404 instead of a login page. Missing RBAC fallback rule. Add the 401! line.

getUser() returns nothing in your function. Check you’re on a v2 function with export default. The v1 export { handler } shape isn’t supported for Identity’s server-side API.

admin.listUsers() fails in an Edge Function. admin.* is Netlify Functions only.

External-provider users bypass your invite-only setting. They don’t. Invite-only requires an invite for all new users, including external-provider logins. What external providers do skip is email confirmation.

You enabled a new OAuth provider and the button didn’t appear. You hard-coded the provider list. Render it from getSettings().

Your custom email template broke. Templates need inline CSS only, absolute image links, and no <html>, <head>, or <body> tags. Also check your build isn’t rewriting the template variables.

Your external JWT tokens are rejected while Identity is on. You can use one or the other, not both. Also confirm the tokens are HS256 with exp in the payload.

Reference

Client API

FunctionWhat it does
signup(email, password, metadata?)Create a user. Sends a confirmation email by default.
login(email, password)Log in
logout()Log out
getUser()Current user, or null. Browser, Functions, and Edge Functions.
oauthLogin(provider)Redirect to provider. 'google', 'github', 'gitlab', 'bitbucket'.
handleAuthCallback()Required. Processes all token types in the URL hash.
recoverPassword()Complete a password reset
acceptInvite()Complete invite acceptance
refreshSession()Refresh the token so new roles take effect
verifyRequestOrigin(req)CSRF guard. Throws 403 on Origin mismatch. Supports { allowedOrigins }.
getSettings()Read enabled providers and registration settings at startup

Identity event handlers

HandlerFires whenCan deny?
userValidateSignup attempted, before account creationYes
userSignupSignup completes. After email confirmation if enabled.Yes
userLoginUser logs inYes
userModifiedProfile updatedYes
userDeletedUser deleted (notification only)No

event.deny() gives the user a 401 with no observability error. With multiple subscribers, the first denial aborts the chain. Event types come from @netlify/functions: UserValidateEvent, UserSignupEvent, UserLoginEvent, UserModifiedEvent, UserDeletedEvent, Config.

The User object and metadata

FieldEditable by user?Stored at
NameYesuser_metadata.full_name
EmailYes. Triggers change confirmation; changes login credentials.user_metadata.email
RolesNoapp_metadata.roles

id, email, and roles are on the User object. Roles are included in the JWT. Edit them in Identity > Users > Edit settings, at signup via a userSignup handler, or for existing users via admin.updateUser().

Sessions

The JWT lives in the nf_jwt cookie and is sent automatically. Server-side login, signup, and logout read and write nf_jwt and nf_refresh through the runtime, so the browser receives the session in the response.

Registration settings (dashboard)

SettingOptions
Registration preferencesOpen (default) or Invite only
ConfirmationOn by default. Skip via Emails > Confirmation template > Configure.
External providersGoogle, GitHub, GitLab, Bitbucket. Set your own client ID and secret for branded OAuth.
InvitationsProject configuration > Identity > Users. Any Netlify team role can invite.

Email templates

Default sender is no-reply@netlify.com. Custom sender and custom templates are Pro and above.

Go template variables: {{ .Email }}, {{ .NewEmail }} (email change only), {{ .SiteURL }}, {{ .ConfirmationURL }}, {{ .Token }}. A custom link takes the form {{ .SiteURL }}/path/#confirmation_token={{ .Token }}, and likewise for invite_token, recovery_token, and email_change_token.

Audit log (Pro and above)

Project configuration > Identity > Identity audit log. Searches need a scope prefix: author:[string] or action:[string]. Actions: login, logout, user_signedup, user_deleted, user_modified, token_revoked, token_refreshed, user_recovery_requested, user_invited.

External JWT provider (Enterprise)

Set the secret at Project configuration > Access & security > Visitor access > JWT secret; a project-level value overrides the team default. Tokens must be HS256 with "alg": "HS256" and "typ": "JWT" in the header, and exp (a future Unix epoch) in the payload. External-provider roles resolve at app_metadata.authorization.roles rather than app_metadata.roles. A different path needs a support-configured custom role path.

Plan gating

FeaturePlans
Identity itself, unlimited users, custom OAuth credentials, Functions integrationAll credit-based plans, no extra cost
Custom outgoing email, custom email templates, audit logPro and above
External JWT providersEnterprise

FAQs

Is Netlify Identity deprecated? No. It was announced as discontinued and that decision was reversed after community pushback. It’s supported and included on all credit-based plans at no extra cost.

How do I add authentication to a Netlify site? Enable Identity in the dashboard under Project configuration > Identity, install @netlify/identity, then use signup(), login(), and getUser(). Call handleAuthCallback() on your landing page to complete confirmation and OAuth flows.

Why doesn’t Netlify Identity work locally? netlify dev can’t exercise the /.netlify/identity/* endpoints. Test auth on a real deploy; Deploy Previews work.

How do I add Google or GitHub login? Enable the provider under Registration > External providers in the dashboard, then call oauthLogin('google') or oauthLogin('github'). Handle the return with handleAuthCallback().

How do I gate a page by user role? Add a Role condition to a redirect rule, and always include a fallback rule so visitors without the role get a login page rather than a raw 404.

Why doesn’t a newly assigned role work immediately? Role changes take effect on next login or token refresh and don’t invalidate the current JWT. Call refreshSession() on the client.

Can I use Netlify Identity for a private, employees-only site? That’s a different feature. Identity is the app-user layer. For locking an entire site or its previews, use site-level access control.

Do I need CSRF protection on my login function? Yes. Call verifyRequestOrigin(req) before login(), signup(), or logout() in any exposed endpoint. Without it an attacker can log a victim into the attacker’s account.

Can I use my own JWT provider instead? On Enterprise, yes, but not at the same time as Identity. You use one or the other.

This article is generated from Netlify’s open-source agent guidance at netlify/context-and-tools, the same reference our AI coding agents use.


Ready to add login? Start at netlify.new.