This is page 33 of 70. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ ├── CODEOWNERS
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.yml
│ │ └── feature_request.yml
│ ├── renovate.json5
│ └── workflows
│ ├── ci.yml
│ ├── e2e.yml
│ ├── preview.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .nvmrc
├── .vscode
│ └── settings.json
├── banner-dark.png
├── banner.png
├── biome.json
├── bump.config.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── demo
│ ├── expo-example
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app.config.ts
│ │ ├── assets
│ │ │ ├── bg-image.jpeg
│ │ │ ├── fonts
│ │ │ │ └── SpaceMono-Regular.ttf
│ │ │ ├── icon.png
│ │ │ └── images
│ │ │ ├── adaptive-icon.png
│ │ │ ├── favicon.png
│ │ │ ├── logo.png
│ │ │ ├── partial-react-logo.png
│ │ │ ├── react-logo.png
│ │ │ ├── [email protected]
│ │ │ ├── [email protected]
│ │ │ └── splash.png
│ │ ├── babel.config.js
│ │ ├── components.json
│ │ ├── expo-env.d.ts
│ │ ├── index.ts
│ │ ├── metro.config.js
│ │ ├── nativewind-env.d.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...route]+api.ts
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── forget-password.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ └── sign-up.tsx
│ │ │ ├── components
│ │ │ │ ├── icons
│ │ │ │ │ └── google.tsx
│ │ │ │ └── ui
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ └── text.tsx
│ │ │ ├── global.css
│ │ │ └── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth.ts
│ │ │ ├── icons
│ │ │ │ ├── iconWithClassName.ts
│ │ │ │ └── X.tsx
│ │ │ └── utils.ts
│ │ ├── tailwind.config.js
│ │ └── tsconfig.json
│ ├── nextjs
│ │ ├── .env.example
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── (auth)
│ │ │ │ ├── forget-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── reset-password
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── two-factor
│ │ │ │ ├── otp
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── accept-invitation
│ │ │ │ └── [id]
│ │ │ │ ├── invitation-error.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── admin
│ │ │ │ └── page.tsx
│ │ │ ├── api
│ │ │ │ └── auth
│ │ │ │ └── [...all]
│ │ │ │ └── route.ts
│ │ │ ├── apps
│ │ │ │ └── register
│ │ │ │ └── page.tsx
│ │ │ ├── client-test
│ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── change-plan.tsx
│ │ │ │ ├── client.tsx
│ │ │ │ ├── organization-card.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── upgrade-button.tsx
│ │ │ │ └── user-card.tsx
│ │ │ ├── device
│ │ │ │ ├── approve
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── denied
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── success
│ │ │ │ └── page.tsx
│ │ │ ├── favicon.ico
│ │ │ ├── features.tsx
│ │ │ ├── fonts
│ │ │ │ ├── GeistMonoVF.woff
│ │ │ │ └── GeistVF.woff
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── oauth
│ │ │ │ └── authorize
│ │ │ │ ├── concet-buttons.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── pricing
│ │ │ └── page.tsx
│ │ ├── components
│ │ │ ├── account-switch.tsx
│ │ │ ├── blocks
│ │ │ │ └── pricing.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── one-tap.tsx
│ │ │ ├── sign-in-btn.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── tier-labels.tsx
│ │ │ ├── ui
│ │ │ │ ├── accordion.tsx
│ │ │ │ ├── alert-dialog.tsx
│ │ │ │ ├── alert.tsx
│ │ │ │ ├── aspect-ratio.tsx
│ │ │ │ ├── avatar.tsx
│ │ │ │ ├── badge.tsx
│ │ │ │ ├── breadcrumb.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ ├── calendar.tsx
│ │ │ │ ├── card.tsx
│ │ │ │ ├── carousel.tsx
│ │ │ │ ├── chart.tsx
│ │ │ │ ├── checkbox.tsx
│ │ │ │ ├── collapsible.tsx
│ │ │ │ ├── command.tsx
│ │ │ │ ├── context-menu.tsx
│ │ │ │ ├── copy-button.tsx
│ │ │ │ ├── dialog.tsx
│ │ │ │ ├── drawer.tsx
│ │ │ │ ├── dropdown-menu.tsx
│ │ │ │ ├── form.tsx
│ │ │ │ ├── hover-card.tsx
│ │ │ │ ├── input-otp.tsx
│ │ │ │ ├── input.tsx
│ │ │ │ ├── label.tsx
│ │ │ │ ├── menubar.tsx
│ │ │ │ ├── navigation-menu.tsx
│ │ │ │ ├── pagination.tsx
│ │ │ │ ├── password-input.tsx
│ │ │ │ ├── popover.tsx
│ │ │ │ ├── progress.tsx
│ │ │ │ ├── radio-group.tsx
│ │ │ │ ├── resizable.tsx
│ │ │ │ ├── scroll-area.tsx
│ │ │ │ ├── select.tsx
│ │ │ │ ├── separator.tsx
│ │ │ │ ├── sheet.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ ├── sonner.tsx
│ │ │ │ ├── switch.tsx
│ │ │ │ ├── table.tsx
│ │ │ │ ├── tabs.tsx
│ │ │ │ ├── tabs2.tsx
│ │ │ │ ├── textarea.tsx
│ │ │ │ ├── toast.tsx
│ │ │ │ ├── toaster.tsx
│ │ │ │ ├── toggle-group.tsx
│ │ │ │ ├── toggle.tsx
│ │ │ │ └── tooltip.tsx
│ │ │ └── wrapper.tsx
│ │ ├── components.json
│ │ ├── hooks
│ │ │ └── use-toast.ts
│ │ ├── lib
│ │ │ ├── auth-client.ts
│ │ │ ├── auth-types.ts
│ │ │ ├── auth.ts
│ │ │ ├── email
│ │ │ │ ├── invitation.tsx
│ │ │ │ ├── resend.ts
│ │ │ │ └── reset-password.tsx
│ │ │ ├── metadata.ts
│ │ │ ├── shared.ts
│ │ │ └── utils.ts
│ │ ├── next.config.ts
│ │ ├── package.json
│ │ ├── postcss.config.mjs
│ │ ├── proxy.ts
│ │ ├── public
│ │ │ ├── __og.png
│ │ │ ├── _og.png
│ │ │ ├── favicon
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ ├── light
│ │ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ │ ├── apple-touch-icon.png
│ │ │ │ │ ├── favicon-16x16.png
│ │ │ │ │ ├── favicon-32x32.png
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ └── site.webmanifest
│ │ │ │ └── site.webmanifest
│ │ │ ├── logo.svg
│ │ │ └── og.png
│ │ ├── README.md
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── turbo.json
│ └── stateless
│ ├── .env.example
│ ├── .gitignore
│ ├── next.config.ts
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── src
│ │ ├── app
│ │ │ ├── api
│ │ │ │ ├── auth
│ │ │ │ │ └── [...all]
│ │ │ │ │ └── route.ts
│ │ │ │ └── user
│ │ │ │ └── route.ts
│ │ │ ├── dashboard
│ │ │ │ └── page.tsx
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ └── lib
│ │ ├── auth-client.ts
│ │ └── auth.ts
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── docker-compose.yml
├── docs
│ ├── .env.example
│ ├── .gitignore
│ ├── app
│ │ ├── api
│ │ │ ├── ai-chat
│ │ │ │ └── route.ts
│ │ │ ├── analytics
│ │ │ │ ├── conversation
│ │ │ │ │ └── route.ts
│ │ │ │ ├── event
│ │ │ │ │ └── route.ts
│ │ │ │ └── feedback
│ │ │ │ └── route.ts
│ │ │ ├── chat
│ │ │ │ └── route.ts
│ │ │ ├── og
│ │ │ │ └── route.tsx
│ │ │ ├── og-release
│ │ │ │ └── route.tsx
│ │ │ ├── search
│ │ │ │ └── route.ts
│ │ │ └── support
│ │ │ └── route.ts
│ │ ├── blog
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── blog-list.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ ├── stat-field.tsx
│ │ │ │ └── support.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── changelogs
│ │ │ ├── _components
│ │ │ │ ├── _layout.tsx
│ │ │ │ ├── changelog-layout.tsx
│ │ │ │ ├── default-changelog.tsx
│ │ │ │ ├── fmt-dates.tsx
│ │ │ │ ├── grid-pattern.tsx
│ │ │ │ ├── icons.tsx
│ │ │ │ └── stat-field.tsx
│ │ │ ├── [[...slug]]
│ │ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ │ ├── community
│ │ │ ├── _components
│ │ │ │ ├── header.tsx
│ │ │ │ └── stats.tsx
│ │ │ └── page.tsx
│ │ ├── docs
│ │ │ ├── [[...slug]]
│ │ │ │ ├── page.client.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── lib
│ │ │ └── get-llm-text.ts
│ │ ├── global.css
│ │ ├── layout.config.tsx
│ │ ├── layout.tsx
│ │ ├── llms.txt
│ │ │ ├── [...slug]
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ ├── reference
│ │ │ └── route.ts
│ │ ├── sitemap.xml
│ │ ├── static.json
│ │ │ └── route.ts
│ │ └── v1
│ │ ├── _components
│ │ │ └── v1-text.tsx
│ │ ├── bg-line.tsx
│ │ └── page.tsx
│ ├── assets
│ │ ├── Geist.ttf
│ │ └── GeistMono.ttf
│ ├── components
│ │ ├── ai-chat-modal.tsx
│ │ ├── anchor-scroll-fix.tsx
│ │ ├── api-method-tabs.tsx
│ │ ├── api-method.tsx
│ │ ├── banner.tsx
│ │ ├── blocks
│ │ │ └── features.tsx
│ │ ├── builder
│ │ │ ├── beam.tsx
│ │ │ ├── code-tabs
│ │ │ │ ├── code-editor.tsx
│ │ │ │ ├── code-tabs.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── tab-bar.tsx
│ │ │ │ └── theme.ts
│ │ │ ├── index.tsx
│ │ │ ├── sign-in.tsx
│ │ │ ├── sign-up.tsx
│ │ │ ├── social-provider.tsx
│ │ │ ├── store.ts
│ │ │ └── tabs.tsx
│ │ ├── display-techstack.tsx
│ │ ├── divider-text.tsx
│ │ ├── docs
│ │ │ ├── docs.client.tsx
│ │ │ ├── docs.tsx
│ │ │ ├── layout
│ │ │ │ ├── nav.tsx
│ │ │ │ ├── theme-toggle.tsx
│ │ │ │ ├── toc-thumb.tsx
│ │ │ │ └── toc.tsx
│ │ │ ├── page.client.tsx
│ │ │ ├── page.tsx
│ │ │ ├── shared.tsx
│ │ │ └── ui
│ │ │ ├── button.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── popover.tsx
│ │ │ └── scroll-area.tsx
│ │ ├── endpoint.tsx
│ │ ├── features.tsx
│ │ ├── floating-ai-search.tsx
│ │ ├── fork-button.tsx
│ │ ├── generate-apple-jwt.tsx
│ │ ├── generate-secret.tsx
│ │ ├── github-stat.tsx
│ │ ├── icons.tsx
│ │ ├── landing
│ │ │ ├── gradient-bg.tsx
│ │ │ ├── grid-pattern.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── section-svg.tsx
│ │ │ ├── section.tsx
│ │ │ ├── spotlight.tsx
│ │ │ └── testimonials.tsx
│ │ ├── logo-context-menu.tsx
│ │ ├── logo.tsx
│ │ ├── markdown-renderer.tsx
│ │ ├── markdown.tsx
│ │ ├── mdx
│ │ │ ├── add-to-cursor.tsx
│ │ │ └── database-tables.tsx
│ │ ├── message-feedback.tsx
│ │ ├── mobile-search-icon.tsx
│ │ ├── nav-bar.tsx
│ │ ├── nav-link.tsx
│ │ ├── nav-mobile.tsx
│ │ ├── promo-card.tsx
│ │ ├── resource-card.tsx
│ │ ├── resource-grid.tsx
│ │ ├── resource-section.tsx
│ │ ├── ripple.tsx
│ │ ├── search-dialog.tsx
│ │ ├── side-bar.tsx
│ │ ├── sidebar-content.tsx
│ │ ├── techstack-icons.tsx
│ │ ├── theme-provider.tsx
│ │ ├── theme-toggler.tsx
│ │ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── aside-link.tsx
│ │ ├── aspect-ratio.tsx
│ │ ├── avatar.tsx
│ │ ├── background-beams.tsx
│ │ ├── background-boxes.tsx
│ │ ├── badge.tsx
│ │ ├── breadcrumb.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── callout.tsx
│ │ ├── card.tsx
│ │ ├── carousel.tsx
│ │ ├── chart.tsx
│ │ ├── checkbox.tsx
│ │ ├── code-block.tsx
│ │ ├── collapsible.tsx
│ │ ├── command.tsx
│ │ ├── context-menu.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── dynamic-code-block.tsx
│ │ ├── fade-in.tsx
│ │ ├── form.tsx
│ │ ├── hover-card.tsx
│ │ ├── input-otp.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── menubar.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── pagination.tsx
│ │ ├── popover.tsx
│ │ ├── progress.tsx
│ │ ├── radio-group.tsx
│ │ ├── resizable.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── sparkles.tsx
│ │ ├── switch.tsx
│ │ ├── table.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ ├── toggle-group.tsx
│ │ ├── toggle.tsx
│ │ ├── tooltip-docs.tsx
│ │ ├── tooltip.tsx
│ │ └── use-copy-button.tsx
│ ├── components.json
│ ├── content
│ │ ├── blogs
│ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx
│ │ │ ├── 1-3.mdx
│ │ │ ├── authjs-joins-better-auth.mdx
│ │ │ └── seed-round.mdx
│ │ ├── changelogs
│ │ │ ├── 1-2.mdx
│ │ │ └── 1.0.mdx
│ │ └── docs
│ │ ├── adapters
│ │ │ ├── community-adapters.mdx
│ │ │ ├── drizzle.mdx
│ │ │ ├── mongo.mdx
│ │ │ ├── mssql.mdx
│ │ │ ├── mysql.mdx
│ │ │ ├── other-relational-databases.mdx
│ │ │ ├── postgresql.mdx
│ │ │ ├── prisma.mdx
│ │ │ └── sqlite.mdx
│ │ ├── authentication
│ │ │ ├── apple.mdx
│ │ │ ├── atlassian.mdx
│ │ │ ├── cognito.mdx
│ │ │ ├── discord.mdx
│ │ │ ├── dropbox.mdx
│ │ │ ├── email-password.mdx
│ │ │ ├── facebook.mdx
│ │ │ ├── figma.mdx
│ │ │ ├── github.mdx
│ │ │ ├── gitlab.mdx
│ │ │ ├── google.mdx
│ │ │ ├── huggingface.mdx
│ │ │ ├── kakao.mdx
│ │ │ ├── kick.mdx
│ │ │ ├── line.mdx
│ │ │ ├── linear.mdx
│ │ │ ├── linkedin.mdx
│ │ │ ├── microsoft.mdx
│ │ │ ├── naver.mdx
│ │ │ ├── notion.mdx
│ │ │ ├── other-social-providers.mdx
│ │ │ ├── paypal.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── reddit.mdx
│ │ │ ├── roblox.mdx
│ │ │ ├── salesforce.mdx
│ │ │ ├── slack.mdx
│ │ │ ├── spotify.mdx
│ │ │ ├── tiktok.mdx
│ │ │ ├── twitch.mdx
│ │ │ ├── twitter.mdx
│ │ │ ├── vk.mdx
│ │ │ └── zoom.mdx
│ │ ├── basic-usage.mdx
│ │ ├── comparison.mdx
│ │ ├── concepts
│ │ │ ├── api.mdx
│ │ │ ├── cli.mdx
│ │ │ ├── client.mdx
│ │ │ ├── cookies.mdx
│ │ │ ├── database.mdx
│ │ │ ├── email.mdx
│ │ │ ├── hooks.mdx
│ │ │ ├── oauth.mdx
│ │ │ ├── plugins.mdx
│ │ │ ├── rate-limit.mdx
│ │ │ ├── session-management.mdx
│ │ │ ├── typescript.mdx
│ │ │ └── users-accounts.mdx
│ │ ├── examples
│ │ │ ├── astro.mdx
│ │ │ ├── next-js.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ └── svelte-kit.mdx
│ │ ├── guides
│ │ │ ├── auth0-migration-guide.mdx
│ │ │ ├── browser-extension-guide.mdx
│ │ │ ├── clerk-migration-guide.mdx
│ │ │ ├── create-a-db-adapter.mdx
│ │ │ ├── next-auth-migration-guide.mdx
│ │ │ ├── optimizing-for-performance.mdx
│ │ │ ├── saml-sso-with-okta.mdx
│ │ │ ├── supabase-migration-guide.mdx
│ │ │ └── your-first-plugin.mdx
│ │ ├── installation.mdx
│ │ ├── integrations
│ │ │ ├── astro.mdx
│ │ │ ├── convex.mdx
│ │ │ ├── elysia.mdx
│ │ │ ├── expo.mdx
│ │ │ ├── express.mdx
│ │ │ ├── fastify.mdx
│ │ │ ├── hono.mdx
│ │ │ ├── lynx.mdx
│ │ │ ├── nestjs.mdx
│ │ │ ├── next.mdx
│ │ │ ├── nitro.mdx
│ │ │ ├── nuxt.mdx
│ │ │ ├── remix.mdx
│ │ │ ├── solid-start.mdx
│ │ │ ├── svelte-kit.mdx
│ │ │ ├── tanstack.mdx
│ │ │ └── waku.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── plugins
│ │ │ ├── 2fa.mdx
│ │ │ ├── admin.mdx
│ │ │ ├── anonymous.mdx
│ │ │ ├── api-key.mdx
│ │ │ ├── autumn.mdx
│ │ │ ├── bearer.mdx
│ │ │ ├── captcha.mdx
│ │ │ ├── community-plugins.mdx
│ │ │ ├── device-authorization.mdx
│ │ │ ├── dodopayments.mdx
│ │ │ ├── dub.mdx
│ │ │ ├── email-otp.mdx
│ │ │ ├── generic-oauth.mdx
│ │ │ ├── have-i-been-pwned.mdx
│ │ │ ├── jwt.mdx
│ │ │ ├── last-login-method.mdx
│ │ │ ├── magic-link.mdx
│ │ │ ├── mcp.mdx
│ │ │ ├── multi-session.mdx
│ │ │ ├── oauth-proxy.mdx
│ │ │ ├── oidc-provider.mdx
│ │ │ ├── one-tap.mdx
│ │ │ ├── one-time-token.mdx
│ │ │ ├── open-api.mdx
│ │ │ ├── organization.mdx
│ │ │ ├── passkey.mdx
│ │ │ ├── phone-number.mdx
│ │ │ ├── polar.mdx
│ │ │ ├── siwe.mdx
│ │ │ ├── sso.mdx
│ │ │ ├── stripe.mdx
│ │ │ └── username.mdx
│ │ └── reference
│ │ ├── contributing.mdx
│ │ ├── faq.mdx
│ │ ├── options.mdx
│ │ ├── resources.mdx
│ │ ├── security.mdx
│ │ └── telemetry.mdx
│ ├── hooks
│ │ └── use-mobile.ts
│ ├── ignore-build.sh
│ ├── lib
│ │ ├── blog.ts
│ │ ├── chat
│ │ │ └── inkeep-qa-schema.ts
│ │ ├── constants.ts
│ │ ├── export-search-indexes.ts
│ │ ├── inkeep-analytics.ts
│ │ ├── is-active.ts
│ │ ├── metadata.ts
│ │ ├── source.ts
│ │ └── utils.ts
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── proxy.ts
│ ├── public
│ │ ├── avatars
│ │ │ └── beka.jpg
│ │ ├── blogs
│ │ │ ├── authjs-joins.png
│ │ │ ├── seed-round.png
│ │ │ └── supabase-ps.png
│ │ ├── branding
│ │ │ ├── better-auth-brand-assets.zip
│ │ │ ├── better-auth-logo-dark.png
│ │ │ ├── better-auth-logo-dark.svg
│ │ │ ├── better-auth-logo-light.png
│ │ │ ├── better-auth-logo-light.svg
│ │ │ ├── better-auth-logo-wordmark-dark.png
│ │ │ ├── better-auth-logo-wordmark-dark.svg
│ │ │ ├── better-auth-logo-wordmark-light.png
│ │ │ └── better-auth-logo-wordmark-light.svg
│ │ ├── extension-id.png
│ │ ├── favicon
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── favicon.ico
│ │ │ ├── light
│ │ │ │ ├── android-chrome-192x192.png
│ │ │ │ ├── android-chrome-512x512.png
│ │ │ │ ├── apple-touch-icon.png
│ │ │ │ ├── favicon-16x16.png
│ │ │ │ ├── favicon-32x32.png
│ │ │ │ ├── favicon.ico
│ │ │ │ └── site.webmanifest
│ │ │ └── site.webmanifest
│ │ ├── images
│ │ │ └── blogs
│ │ │ └── better auth (1).png
│ │ ├── logo.png
│ │ ├── logo.svg
│ │ ├── LogoDark.webp
│ │ ├── LogoLight.webp
│ │ ├── og.png
│ │ ├── open-api-reference.png
│ │ ├── people-say
│ │ │ ├── code-with-antonio.jpg
│ │ │ ├── dagmawi-babi.png
│ │ │ ├── dax.png
│ │ │ ├── dev-ed.png
│ │ │ ├── egoist.png
│ │ │ ├── guillermo-rauch.png
│ │ │ ├── jonathan-wilke.png
│ │ │ ├── josh-tried-coding.jpg
│ │ │ ├── kitze.jpg
│ │ │ ├── lazar-nikolov.png
│ │ │ ├── nizzy.png
│ │ │ ├── omar-mcadam.png
│ │ │ ├── ryan-vogel.jpg
│ │ │ ├── saltyatom.jpg
│ │ │ ├── sebastien-chopin.png
│ │ │ ├── shreyas-mididoddi.png
│ │ │ ├── tech-nerd.png
│ │ │ ├── theo.png
│ │ │ ├── vybhav-bhargav.png
│ │ │ └── xavier-pladevall.jpg
│ │ ├── plus.svg
│ │ ├── release-og
│ │ │ ├── 1-2.png
│ │ │ ├── 1-3.png
│ │ │ └── changelog-og.png
│ │ └── v1-og.png
│ ├── README.md
│ ├── scripts
│ │ ├── endpoint-to-doc
│ │ │ ├── index.ts
│ │ │ ├── input.ts
│ │ │ ├── output.mdx
│ │ │ └── readme.md
│ │ └── sync-orama.ts
│ ├── source.config.ts
│ ├── tsconfig.json
│ └── turbo.json
├── e2e
│ ├── integration
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── solid-vinxi
│ │ │ ├── .gitignore
│ │ │ ├── app.config.ts
│ │ │ ├── e2e
│ │ │ │ ├── test.spec.ts
│ │ │ │ └── utils.ts
│ │ │ ├── package.json
│ │ │ ├── public
│ │ │ │ └── favicon.ico
│ │ │ ├── src
│ │ │ │ ├── app.tsx
│ │ │ │ ├── entry-client.tsx
│ │ │ │ ├── entry-server.tsx
│ │ │ │ ├── global.d.ts
│ │ │ │ ├── lib
│ │ │ │ │ ├── auth-client.ts
│ │ │ │ │ └── auth.ts
│ │ │ │ └── routes
│ │ │ │ ├── [...404].tsx
│ │ │ │ ├── api
│ │ │ │ │ └── auth
│ │ │ │ │ └── [...all].ts
│ │ │ │ └── index.tsx
│ │ │ └── tsconfig.json
│ │ ├── test-utils
│ │ │ ├── package.json
│ │ │ └── src
│ │ │ └── playwright.ts
│ │ └── vanilla-node
│ │ ├── e2e
│ │ │ ├── app.ts
│ │ │ ├── domain.spec.ts
│ │ │ ├── postgres-js.spec.ts
│ │ │ ├── test.spec.ts
│ │ │ └── utils.ts
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── smoke
│ ├── package.json
│ ├── test
│ │ ├── bun.spec.ts
│ │ ├── cloudflare.spec.ts
│ │ ├── deno.spec.ts
│ │ ├── fixtures
│ │ │ ├── bun-simple.ts
│ │ │ ├── cloudflare
│ │ │ │ ├── .gitignore
│ │ │ │ ├── drizzle
│ │ │ │ │ ├── 0000_clean_vector.sql
│ │ │ │ │ └── meta
│ │ │ │ │ ├── _journal.json
│ │ │ │ │ └── 0000_snapshot.json
│ │ │ │ ├── drizzle.config.ts
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── auth-schema.ts
│ │ │ │ │ ├── db.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test
│ │ │ │ │ ├── apply-migrations.ts
│ │ │ │ │ ├── env.d.ts
│ │ │ │ │ └── index.test.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.ts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ └── wrangler.json
│ │ │ ├── deno-simple.ts
│ │ │ ├── tsconfig-declaration
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── demo.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-exact-optional-property-types
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── user-additional-fields.ts
│ │ │ │ │ └── username.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-isolated-module-bundler
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── tsconfig-verbatim-module-syntax-node10
│ │ │ │ ├── package.json
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── vite
│ │ │ ├── package.json
│ │ │ ├── src
│ │ │ │ ├── client.ts
│ │ │ │ └── server.ts
│ │ │ ├── tsconfig.json
│ │ │ └── vite.config.ts
│ │ ├── ssr.ts
│ │ ├── typecheck.spec.ts
│ │ └── vite.spec.ts
│ └── tsconfig.json
├── LICENSE.md
├── package.json
├── packages
│ ├── better-auth
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── __snapshots__
│ │ │ │ └── init.test.ts.snap
│ │ │ ├── adapters
│ │ │ │ ├── adapter-factory
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── __snapshots__
│ │ │ │ │ │ │ └── adapter-factory.test.ts.snap
│ │ │ │ │ │ └── adapter-factory.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── create-test-suite.ts
│ │ │ │ ├── drizzle-adapter
│ │ │ │ │ ├── drizzle-adapter.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── adapter.drizzle.mysql.test.ts
│ │ │ │ │ ├── adapter.drizzle.pg.test.ts
│ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts
│ │ │ │ │ └── generate-schema.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely-adapter
│ │ │ │ │ ├── bun-sqlite-dialect.ts
│ │ │ │ │ ├── dialect.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── kysely-adapter.ts
│ │ │ │ │ ├── node-sqlite-dialect.ts
│ │ │ │ │ ├── test
│ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg-custom-schema.test.ts
│ │ │ │ │ │ ├── adapter.kysely.pg.test.ts
│ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts
│ │ │ │ │ │ └── node-sqlite-dialect.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── memory-adapter
│ │ │ │ │ ├── adapter.memory.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── memory-adapter.ts
│ │ │ │ ├── mongodb-adapter
│ │ │ │ │ ├── adapter.mongo-db.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mongodb-adapter.ts
│ │ │ │ ├── prisma-adapter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── prisma-adapter.ts
│ │ │ │ │ └── test
│ │ │ │ │ ├── .gitignore
│ │ │ │ │ ├── base.prisma
│ │ │ │ │ ├── generate-auth-config.ts
│ │ │ │ │ ├── generate-prisma-schema.ts
│ │ │ │ │ ├── get-prisma-client.ts
│ │ │ │ │ ├── prisma.mysql.test.ts
│ │ │ │ │ ├── prisma.pg.test.ts
│ │ │ │ │ ├── prisma.sqlite.test.ts
│ │ │ │ │ └── push-prisma-schema.ts
│ │ │ │ ├── test-adapter.ts
│ │ │ │ ├── test.ts
│ │ │ │ ├── tests
│ │ │ │ │ ├── auth-flow.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── normal.ts
│ │ │ │ │ ├── number-id.ts
│ │ │ │ │ ├── performance.ts
│ │ │ │ │ └── transactions.ts
│ │ │ │ └── utils.ts
│ │ │ ├── api
│ │ │ │ ├── check-endpoint-conflicts.test.ts
│ │ │ │ ├── index.test.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── middlewares
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── origin-check.test.ts
│ │ │ │ │ └── origin-check.ts
│ │ │ │ ├── rate-limiter
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── rate-limiter.test.ts
│ │ │ │ ├── routes
│ │ │ │ │ ├── account.test.ts
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── callback.ts
│ │ │ │ │ ├── email-verification.test.ts
│ │ │ │ │ ├── email-verification.ts
│ │ │ │ │ ├── error.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── ok.ts
│ │ │ │ │ ├── reset-password.test.ts
│ │ │ │ │ ├── reset-password.ts
│ │ │ │ │ ├── session-api.test.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── sign-in.test.ts
│ │ │ │ │ ├── sign-in.ts
│ │ │ │ │ ├── sign-out.test.ts
│ │ │ │ │ ├── sign-out.ts
│ │ │ │ │ ├── sign-up.test.ts
│ │ │ │ │ ├── sign-up.ts
│ │ │ │ │ ├── update-user.test.ts
│ │ │ │ │ └── update-user.ts
│ │ │ │ ├── to-auth-endpoints.test.ts
│ │ │ │ └── to-auth-endpoints.ts
│ │ │ ├── auth.test.ts
│ │ │ ├── auth.ts
│ │ │ ├── call.test.ts
│ │ │ ├── client
│ │ │ │ ├── client-ssr.test.ts
│ │ │ │ ├── client.test.ts
│ │ │ │ ├── config.ts
│ │ │ │ ├── fetch-plugins.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── lynx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── lynx-store.ts
│ │ │ │ ├── parser.ts
│ │ │ │ ├── path-to-object.ts
│ │ │ │ ├── plugins
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── infer-plugin.ts
│ │ │ │ ├── proxy.ts
│ │ │ │ ├── query.ts
│ │ │ │ ├── react
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── react-store.ts
│ │ │ │ ├── session-atom.ts
│ │ │ │ ├── solid
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── solid-store.ts
│ │ │ │ ├── svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── test-plugin.ts
│ │ │ │ ├── types.ts
│ │ │ │ ├── url.test.ts
│ │ │ │ ├── vanilla.ts
│ │ │ │ └── vue
│ │ │ │ ├── index.ts
│ │ │ │ └── vue-store.ts
│ │ │ ├── cookies
│ │ │ │ ├── check-cookies.ts
│ │ │ │ ├── cookie-utils.ts
│ │ │ │ ├── cookies.test.ts
│ │ │ │ └── index.ts
│ │ │ ├── crypto
│ │ │ │ ├── buffer.ts
│ │ │ │ ├── hash.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt.ts
│ │ │ │ ├── password.test.ts
│ │ │ │ ├── password.ts
│ │ │ │ └── random.ts
│ │ │ ├── db
│ │ │ │ ├── db.test.ts
│ │ │ │ ├── field.ts
│ │ │ │ ├── get-migration-schema.test.ts
│ │ │ │ ├── get-migration.ts
│ │ │ │ ├── get-schema.ts
│ │ │ │ ├── get-tables.test.ts
│ │ │ │ ├── get-tables.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── internal-adapter.test.ts
│ │ │ │ ├── internal-adapter.ts
│ │ │ │ ├── schema.ts
│ │ │ │ ├── secondary-storage.test.ts
│ │ │ │ ├── to-zod.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── with-hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── init.test.ts
│ │ │ ├── init.ts
│ │ │ ├── integrations
│ │ │ │ ├── next-js.ts
│ │ │ │ ├── node.ts
│ │ │ │ ├── react-start.ts
│ │ │ │ ├── solid-start.ts
│ │ │ │ └── svelte-kit.ts
│ │ │ ├── oauth2
│ │ │ │ ├── index.ts
│ │ │ │ ├── link-account.test.ts
│ │ │ │ ├── link-account.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── utils.ts
│ │ │ ├── plugins
│ │ │ │ ├── access
│ │ │ │ │ ├── access.test.ts
│ │ │ │ │ ├── access.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── additional-fields
│ │ │ │ │ ├── additional-fields.test.ts
│ │ │ │ │ └── client.ts
│ │ │ │ ├── admin
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── admin.test.ts
│ │ │ │ │ ├── admin.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── anonymous
│ │ │ │ │ ├── anon.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── api-key
│ │ │ │ │ ├── api-key.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── create-api-key.ts
│ │ │ │ │ │ ├── delete-all-expired-api-keys.ts
│ │ │ │ │ │ ├── delete-api-key.ts
│ │ │ │ │ │ ├── get-api-key.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── list-api-keys.ts
│ │ │ │ │ │ ├── update-api-key.ts
│ │ │ │ │ │ └── verify-api-key.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── bearer
│ │ │ │ │ ├── bearer.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── captcha
│ │ │ │ │ ├── captcha.test.ts
│ │ │ │ │ ├── constants.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-handlers
│ │ │ │ │ ├── captchafox.ts
│ │ │ │ │ ├── cloudflare-turnstile.ts
│ │ │ │ │ ├── google-recaptcha.ts
│ │ │ │ │ ├── h-captcha.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── custom-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-session.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── device-authorization
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── device-authorization.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── schema.ts
│ │ │ │ ├── email-otp
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── email-otp.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── generic-oauth
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── generic-oauth.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── haveibeenpwned
│ │ │ │ │ ├── haveibeenpwned.test.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── jwt
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── jwt.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── sign.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── last-login-method
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── custom-prefix.test.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── last-login-method.test.ts
│ │ │ │ ├── magic-link
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── magic-link.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── mcp
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── mcp.test.ts
│ │ │ │ ├── multi-session
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── multi-session.test.ts
│ │ │ │ ├── oauth-proxy
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── oauth-proxy.test.ts
│ │ │ │ ├── oidc-provider
│ │ │ │ │ ├── authorize.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── oidc.test.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── ui.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── one-tap
│ │ │ │ │ ├── client.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── one-time-token
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── one-time-token.test.ts
│ │ │ │ │ └── utils.ts
│ │ │ │ ├── open-api
│ │ │ │ │ ├── generator.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── logo.ts
│ │ │ │ │ └── open-api.test.ts
│ │ │ │ ├── organization
│ │ │ │ │ ├── access
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ └── statement.ts
│ │ │ │ │ ├── adapter.ts
│ │ │ │ │ ├── call.ts
│ │ │ │ │ ├── client.test.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── error-codes.ts
│ │ │ │ │ ├── has-permission.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── organization-hook.test.ts
│ │ │ │ │ ├── organization.test.ts
│ │ │ │ │ ├── organization.ts
│ │ │ │ │ ├── permission.ts
│ │ │ │ │ ├── routes
│ │ │ │ │ │ ├── crud-access-control.test.ts
│ │ │ │ │ │ ├── crud-access-control.ts
│ │ │ │ │ │ ├── crud-invites.ts
│ │ │ │ │ │ ├── crud-members.test.ts
│ │ │ │ │ │ ├── crud-members.ts
│ │ │ │ │ │ ├── crud-org.test.ts
│ │ │ │ │ │ ├── crud-org.ts
│ │ │ │ │ │ └── crud-team.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── team.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── passkey
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── passkey.test.ts
│ │ │ │ ├── phone-number
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── phone-number-error.ts
│ │ │ │ │ └── phone-number.test.ts
│ │ │ │ ├── siwe
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── siwe.test.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── two-factor
│ │ │ │ │ ├── backup-codes
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── constant.ts
│ │ │ │ │ ├── error-code.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── otp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── schema.ts
│ │ │ │ │ ├── totp
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ ├── two-factor.test.ts
│ │ │ │ │ ├── types.ts
│ │ │ │ │ ├── utils.ts
│ │ │ │ │ └── verify-two-factor.ts
│ │ │ │ └── username
│ │ │ │ ├── client.ts
│ │ │ │ ├── error-codes.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── schema.ts
│ │ │ │ └── username.test.ts
│ │ │ ├── social-providers
│ │ │ │ └── index.ts
│ │ │ ├── social.test.ts
│ │ │ ├── test-utils
│ │ │ │ ├── headers.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── state.ts
│ │ │ │ └── test-instance.ts
│ │ │ ├── types
│ │ │ │ ├── adapter.ts
│ │ │ │ ├── api.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models.ts
│ │ │ │ ├── plugins.ts
│ │ │ │ └── types.test.ts
│ │ │ └── utils
│ │ │ ├── await-object.ts
│ │ │ ├── boolean.ts
│ │ │ ├── clone.ts
│ │ │ ├── constants.ts
│ │ │ ├── date.ts
│ │ │ ├── ensure-utc.ts
│ │ │ ├── get-request-ip.ts
│ │ │ ├── hashing.ts
│ │ │ ├── hide-metadata.ts
│ │ │ ├── id.ts
│ │ │ ├── import-util.ts
│ │ │ ├── index.ts
│ │ │ ├── is-atom.ts
│ │ │ ├── is-promise.ts
│ │ │ ├── json.ts
│ │ │ ├── merger.ts
│ │ │ ├── middleware-response.ts
│ │ │ ├── misc.ts
│ │ │ ├── password.ts
│ │ │ ├── plugin-helper.ts
│ │ │ ├── shim.ts
│ │ │ ├── time.ts
│ │ │ ├── url.ts
│ │ │ └── wildcard.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ ├── vitest.config.ts
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── commands
│ │ │ │ ├── generate.ts
│ │ │ │ ├── info.ts
│ │ │ │ ├── init.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── mcp.ts
│ │ │ │ ├── migrate.ts
│ │ │ │ └── secret.ts
│ │ │ ├── generators
│ │ │ │ ├── auth-config.ts
│ │ │ │ ├── drizzle.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kysely.ts
│ │ │ │ ├── prisma.ts
│ │ │ │ └── types.ts
│ │ │ ├── index.ts
│ │ │ └── utils
│ │ │ ├── add-svelte-kit-env-modules.ts
│ │ │ ├── check-package-managers.ts
│ │ │ ├── format-ms.ts
│ │ │ ├── get-config.ts
│ │ │ ├── get-package-info.ts
│ │ │ ├── get-tsconfig-info.ts
│ │ │ └── install-dependencies.ts
│ │ ├── test
│ │ │ ├── __snapshots__
│ │ │ │ ├── auth-schema-mysql-enum.txt
│ │ │ │ ├── auth-schema-mysql-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt
│ │ │ │ ├── auth-schema-mysql-passkey.txt
│ │ │ │ ├── auth-schema-mysql.txt
│ │ │ │ ├── auth-schema-number-id.txt
│ │ │ │ ├── auth-schema-pg-enum.txt
│ │ │ │ ├── auth-schema-pg-passkey.txt
│ │ │ │ ├── auth-schema-sqlite-enum.txt
│ │ │ │ ├── auth-schema-sqlite-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt
│ │ │ │ ├── auth-schema-sqlite-passkey.txt
│ │ │ │ ├── auth-schema-sqlite.txt
│ │ │ │ ├── auth-schema.txt
│ │ │ │ ├── migrations.sql
│ │ │ │ ├── schema-mongodb.prisma
│ │ │ │ ├── schema-mysql-custom.prisma
│ │ │ │ ├── schema-mysql.prisma
│ │ │ │ ├── schema-numberid.prisma
│ │ │ │ └── schema.prisma
│ │ │ ├── generate-all-db.test.ts
│ │ │ ├── generate.test.ts
│ │ │ ├── get-config.test.ts
│ │ │ ├── info.test.ts
│ │ │ └── migrate.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── core
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── api
│ │ │ │ └── index.ts
│ │ │ ├── async_hooks
│ │ │ │ └── index.ts
│ │ │ ├── context
│ │ │ │ ├── endpoint-context.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── transaction.ts
│ │ │ ├── db
│ │ │ │ ├── adapter
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── plugin.ts
│ │ │ │ ├── schema
│ │ │ │ │ ├── account.ts
│ │ │ │ │ ├── rate-limit.ts
│ │ │ │ │ ├── session.ts
│ │ │ │ │ ├── shared.ts
│ │ │ │ │ ├── user.ts
│ │ │ │ │ └── verification.ts
│ │ │ │ └── type.ts
│ │ │ ├── env
│ │ │ │ ├── color-depth.ts
│ │ │ │ ├── env-impl.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── logger.test.ts
│ │ │ │ └── logger.ts
│ │ │ ├── error
│ │ │ │ ├── codes.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── oauth2
│ │ │ │ ├── client-credentials-token.ts
│ │ │ │ ├── create-authorization-url.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── oauth-provider.ts
│ │ │ │ ├── refresh-access-token.ts
│ │ │ │ ├── utils.ts
│ │ │ │ └── validate-authorization-code.ts
│ │ │ ├── social-providers
│ │ │ │ ├── apple.ts
│ │ │ │ ├── atlassian.ts
│ │ │ │ ├── cognito.ts
│ │ │ │ ├── discord.ts
│ │ │ │ ├── dropbox.ts
│ │ │ │ ├── facebook.ts
│ │ │ │ ├── figma.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── gitlab.ts
│ │ │ │ ├── google.ts
│ │ │ │ ├── huggingface.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── kakao.ts
│ │ │ │ ├── kick.ts
│ │ │ │ ├── line.ts
│ │ │ │ ├── linear.ts
│ │ │ │ ├── linkedin.ts
│ │ │ │ ├── microsoft-entra-id.ts
│ │ │ │ ├── naver.ts
│ │ │ │ ├── notion.ts
│ │ │ │ ├── paypal.ts
│ │ │ │ ├── polar.ts
│ │ │ │ ├── reddit.ts
│ │ │ │ ├── roblox.ts
│ │ │ │ ├── salesforce.ts
│ │ │ │ ├── slack.ts
│ │ │ │ ├── spotify.ts
│ │ │ │ ├── tiktok.ts
│ │ │ │ ├── twitch.ts
│ │ │ │ ├── twitter.ts
│ │ │ │ ├── vk.ts
│ │ │ │ └── zoom.ts
│ │ │ ├── types
│ │ │ │ ├── context.ts
│ │ │ │ ├── cookie.ts
│ │ │ │ ├── helper.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── init-options.ts
│ │ │ │ ├── plugin-client.ts
│ │ │ │ └── plugin.ts
│ │ │ └── utils
│ │ │ ├── error-codes.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── expo
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── expo.test.ts
│ │ ├── tsconfig.json
│ │ ├── tsconfig.test.json
│ │ └── tsdown.config.ts
│ ├── sso
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── oidc.test.ts
│ │ │ └── saml.test.ts
│ │ ├── tsconfig.json
│ │ └── tsdown.config.ts
│ ├── stripe
│ │ ├── CHANGELOG.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ ├── schema.ts
│ │ │ ├── stripe.test.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── tsconfig.json
│ │ ├── tsdown.config.ts
│ │ └── vitest.config.ts
│ └── telemetry
│ ├── package.json
│ ├── src
│ │ ├── detectors
│ │ │ ├── detect-auth-config.ts
│ │ │ ├── detect-database.ts
│ │ │ ├── detect-framework.ts
│ │ │ ├── detect-project-info.ts
│ │ │ ├── detect-runtime.ts
│ │ │ └── detect-system-info.ts
│ │ ├── index.ts
│ │ ├── project-id.ts
│ │ ├── telemetry.test.ts
│ │ ├── types.ts
│ │ └── utils
│ │ ├── hash.ts
│ │ ├── id.ts
│ │ ├── import-util.ts
│ │ └── package-json.ts
│ ├── tsconfig.json
│ └── tsdown.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── SECURITY.md
├── tsconfig.base.json
├── tsconfig.json
└── turbo.json
```
# Files
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/magic-link/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as z from "zod";
2 | import { createAuthEndpoint } from "@better-auth/core/api";
3 | import type { BetterAuthPlugin } from "@better-auth/core";
4 | import { APIError } from "better-call";
5 | import { setSessionCookie } from "../../cookies";
6 | import { generateRandomString } from "../../crypto";
7 | import { BASE_ERROR_CODES } from "@better-auth/core/error";
8 | import { originCheck } from "../../api";
9 | import { defaultKeyHasher } from "./utils";
10 | import type { GenericEndpointContext } from "@better-auth/core";
11 |
12 | interface MagicLinkopts {
13 | /**
14 | * Time in seconds until the magic link expires.
15 | * @default (60 * 5) // 5 minutes
16 | */
17 | expiresIn?: number;
18 | /**
19 | * Send magic link implementation.
20 | */
21 | sendMagicLink: (
22 | data: {
23 | email: string;
24 | url: string;
25 | token: string;
26 | },
27 | request?: Request,
28 | ) => Promise<void> | void;
29 | /**
30 | * Disable sign up if user is not found.
31 | *
32 | * @default false
33 | */
34 | disableSignUp?: boolean;
35 | /**
36 | * Rate limit configuration.
37 | *
38 | * @default {
39 | * window: 60,
40 | * max: 5,
41 | * }
42 | */
43 | rateLimit?: {
44 | window: number;
45 | max: number;
46 | };
47 | /**
48 | * Custom function to generate a token
49 | */
50 | generateToken?: (email: string) => Promise<string> | string;
51 |
52 | /**
53 | * This option allows you to configure how the token is stored in your database.
54 | * Note: This will not affect the token that's sent, it will only affect the token stored in your database.
55 | *
56 | * @default "plain"
57 | */
58 | storeToken?:
59 | | "plain"
60 | | "hashed"
61 | | { type: "custom-hasher"; hash: (token: string) => Promise<string> };
62 | }
63 |
64 | export const magicLink = (options: MagicLinkopts) => {
65 | const opts = {
66 | storeToken: "plain",
67 | ...options,
68 | } satisfies MagicLinkopts;
69 |
70 | async function storeToken(ctx: GenericEndpointContext, token: string) {
71 | if (opts.storeToken === "hashed") {
72 | return await defaultKeyHasher(token);
73 | }
74 | if (
75 | typeof opts.storeToken === "object" &&
76 | "type" in opts.storeToken &&
77 | opts.storeToken.type === "custom-hasher"
78 | ) {
79 | return await opts.storeToken.hash(token);
80 | }
81 | return token;
82 | }
83 |
84 | return {
85 | id: "magic-link",
86 | endpoints: {
87 | /**
88 | * ### Endpoint
89 | *
90 | * POST `/sign-in/magic-link`
91 | *
92 | * ### API Methods
93 | *
94 | * **server:**
95 | * `auth.api.signInMagicLink`
96 | *
97 | * **client:**
98 | * `authClient.signIn.magicLink`
99 | *
100 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/sign-in#api-method-sign-in-magic-link)
101 | */
102 | signInMagicLink: createAuthEndpoint(
103 | "/sign-in/magic-link",
104 | {
105 | method: "POST",
106 | requireHeaders: true,
107 | body: z.object({
108 | email: z
109 | .string()
110 | .meta({
111 | description: "Email address to send the magic link",
112 | })
113 | .email(),
114 | name: z
115 | .string()
116 | .meta({
117 | description:
118 | 'User display name. Only used if the user is registering for the first time. Eg: "my-name"',
119 | })
120 | .optional(),
121 | callbackURL: z
122 | .string()
123 | .meta({
124 | description: "URL to redirect after magic link verification",
125 | })
126 | .optional(),
127 | newUserCallbackURL: z
128 | .string()
129 | .meta({
130 | description:
131 | "URL to redirect after new user signup. Only used if the user is registering for the first time.",
132 | })
133 | .optional(),
134 | errorCallbackURL: z
135 | .string()
136 | .meta({
137 | description: "URL to redirect after error.",
138 | })
139 | .optional(),
140 | }),
141 | metadata: {
142 | openapi: {
143 | description: "Sign in with magic link",
144 | responses: {
145 | 200: {
146 | description: "Success",
147 | content: {
148 | "application/json": {
149 | schema: {
150 | type: "object",
151 | properties: {
152 | status: {
153 | type: "boolean",
154 | },
155 | },
156 | },
157 | },
158 | },
159 | },
160 | },
161 | },
162 | },
163 | },
164 | async (ctx) => {
165 | const { email } = ctx.body;
166 |
167 | if (opts.disableSignUp) {
168 | const user =
169 | await ctx.context.internalAdapter.findUserByEmail(email);
170 |
171 | if (!user) {
172 | throw new APIError("BAD_REQUEST", {
173 | message: BASE_ERROR_CODES.USER_NOT_FOUND,
174 | });
175 | }
176 | }
177 |
178 | const verificationToken = opts?.generateToken
179 | ? await opts.generateToken(email)
180 | : generateRandomString(32, "a-z", "A-Z");
181 | const storedToken = await storeToken(ctx, verificationToken);
182 | await ctx.context.internalAdapter.createVerificationValue({
183 | identifier: storedToken,
184 | value: JSON.stringify({ email, name: ctx.body.name }),
185 | expiresAt: new Date(Date.now() + (opts.expiresIn || 60 * 5) * 1000),
186 | });
187 | const realBaseURL = new URL(ctx.context.baseURL);
188 | const pathname =
189 | realBaseURL.pathname === "/" ? "" : realBaseURL.pathname;
190 | const basePath = pathname ? "" : ctx.context.options.basePath || "";
191 | const url = new URL(
192 | `${pathname}${basePath}/magic-link/verify`,
193 | realBaseURL.origin,
194 | );
195 | url.searchParams.set("token", verificationToken);
196 | url.searchParams.set("callbackURL", ctx.body.callbackURL || "/");
197 | if (ctx.body.newUserCallbackURL) {
198 | url.searchParams.set(
199 | "newUserCallbackURL",
200 | ctx.body.newUserCallbackURL,
201 | );
202 | }
203 | if (ctx.body.errorCallbackURL) {
204 | url.searchParams.set("errorCallbackURL", ctx.body.errorCallbackURL);
205 | }
206 | await options.sendMagicLink(
207 | {
208 | email,
209 | url: url.toString(),
210 | token: verificationToken,
211 | },
212 | ctx.request,
213 | );
214 | return ctx.json({
215 | status: true,
216 | });
217 | },
218 | ),
219 | /**
220 | * ### Endpoint
221 | *
222 | * GET `/magic-link/verify`
223 | *
224 | * ### API Methods
225 | *
226 | * **server:**
227 | * `auth.api.magicLinkVerify`
228 | *
229 | * **client:**
230 | * `authClient.magicLink.verify`
231 | *
232 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/magic-link#api-method-magic-link-verify)
233 | */
234 | magicLinkVerify: createAuthEndpoint(
235 | "/magic-link/verify",
236 | {
237 | method: "GET",
238 | query: z.object({
239 | token: z.string().meta({
240 | description: "Verification token",
241 | }),
242 | callbackURL: z
243 | .string()
244 | .meta({
245 | description:
246 | 'URL to redirect after magic link verification, if not provided the user will be redirected to the root URL. Eg: "/dashboard"',
247 | })
248 | .optional(),
249 | errorCallbackURL: z
250 | .string()
251 | .meta({
252 | description: "URL to redirect after error.",
253 | })
254 | .optional(),
255 | newUserCallbackURL: z
256 | .string()
257 | .meta({
258 | description:
259 | "URL to redirect after new user signup. Only used if the user is registering for the first time.",
260 | })
261 | .optional(),
262 | }),
263 | use: [
264 | originCheck((ctx) => {
265 | return ctx.query.callbackURL
266 | ? decodeURIComponent(ctx.query.callbackURL)
267 | : "/";
268 | }),
269 | originCheck((ctx) => {
270 | return ctx.query.newUserCallbackURL
271 | ? decodeURIComponent(ctx.query.newUserCallbackURL)
272 | : "/";
273 | }),
274 | originCheck((ctx) => {
275 | return ctx.query.errorCallbackURL
276 | ? decodeURIComponent(ctx.query.errorCallbackURL)
277 | : "/";
278 | }),
279 | ],
280 | requireHeaders: true,
281 | metadata: {
282 | openapi: {
283 | description: "Verify magic link",
284 | responses: {
285 | 200: {
286 | description: "Success",
287 | content: {
288 | "application/json": {
289 | schema: {
290 | type: "object",
291 | properties: {
292 | session: {
293 | $ref: "#/components/schemas/Session",
294 | },
295 | user: {
296 | $ref: "#/components/schemas/User",
297 | },
298 | },
299 | },
300 | },
301 | },
302 | },
303 | },
304 | },
305 | },
306 | },
307 | async (ctx) => {
308 | const token = ctx.query.token;
309 | // If the first argument provides the origin, it will ignore the second argument of `new URL`.
310 | // new URL("http://localhost:3001/hello", "http://localhost:3000").toString()
311 | // Returns http://localhost:3001/hello
312 | const callbackURL = new URL(
313 | ctx.query.callbackURL
314 | ? decodeURIComponent(ctx.query.callbackURL)
315 | : "/",
316 | ctx.context.baseURL,
317 | ).toString();
318 | const errorCallbackURL = new URL(
319 | ctx.query.errorCallbackURL
320 | ? decodeURIComponent(ctx.query.errorCallbackURL)
321 | : callbackURL,
322 | ctx.context.baseURL,
323 | ).toString();
324 | const newUserCallbackURL = new URL(
325 | ctx.query.newUserCallbackURL
326 | ? decodeURIComponent(ctx.query.newUserCallbackURL)
327 | : callbackURL,
328 | ctx.context.baseURL,
329 | ).toString();
330 | const toRedirectTo = callbackURL?.startsWith("http")
331 | ? callbackURL
332 | : callbackURL
333 | ? `${ctx.context.options.baseURL}${callbackURL}`
334 | : ctx.context.options.baseURL;
335 | const storedToken = await storeToken(ctx, token);
336 | const tokenValue =
337 | await ctx.context.internalAdapter.findVerificationValue(
338 | storedToken,
339 | );
340 | if (!tokenValue) {
341 | throw ctx.redirect(`${errorCallbackURL}?error=INVALID_TOKEN`);
342 | }
343 | if (tokenValue.expiresAt < new Date()) {
344 | await ctx.context.internalAdapter.deleteVerificationValue(
345 | tokenValue.id,
346 | );
347 | throw ctx.redirect(`${errorCallbackURL}?error=EXPIRED_TOKEN`);
348 | }
349 | await ctx.context.internalAdapter.deleteVerificationValue(
350 | tokenValue.id,
351 | );
352 | const { email, name } = JSON.parse(tokenValue.value) as {
353 | email: string;
354 | name?: string;
355 | };
356 | let isNewUser = false;
357 | let user = await ctx.context.internalAdapter
358 | .findUserByEmail(email)
359 | .then((res) => res?.user);
360 |
361 | if (!user) {
362 | if (!opts.disableSignUp) {
363 | const newUser = await ctx.context.internalAdapter.createUser({
364 | email: email,
365 | emailVerified: true,
366 | name: name || "",
367 | });
368 | isNewUser = true;
369 | user = newUser;
370 | if (!user) {
371 | throw ctx.redirect(
372 | `${errorCallbackURL}?error=failed_to_create_user`,
373 | );
374 | }
375 | } else {
376 | throw ctx.redirect(
377 | `${errorCallbackURL}?error=new_user_signup_disabled`,
378 | );
379 | }
380 | }
381 |
382 | if (!user.emailVerified) {
383 | await ctx.context.internalAdapter.updateUser(user.id, {
384 | emailVerified: true,
385 | });
386 | }
387 |
388 | const session = await ctx.context.internalAdapter.createSession(
389 | user.id,
390 | );
391 |
392 | if (!session) {
393 | throw ctx.redirect(
394 | `${errorCallbackURL}?error=failed_to_create_session`,
395 | );
396 | }
397 |
398 | await setSessionCookie(ctx, {
399 | session,
400 | user,
401 | });
402 | if (!ctx.query.callbackURL) {
403 | return ctx.json({
404 | token: session.token,
405 | user: {
406 | id: user.id,
407 | email: user.email,
408 | emailVerified: user.emailVerified,
409 | name: user.name,
410 | image: user.image,
411 | createdAt: user.createdAt,
412 | updatedAt: user.updatedAt,
413 | },
414 | });
415 | }
416 | if (isNewUser) {
417 | throw ctx.redirect(newUserCallbackURL);
418 | }
419 | throw ctx.redirect(callbackURL);
420 | },
421 | ),
422 | },
423 | rateLimit: [
424 | {
425 | pathMatcher(path) {
426 | return (
427 | path.startsWith("/sign-in/magic-link") ||
428 | path.startsWith("/magic-link/verify")
429 | );
430 | },
431 | window: opts.rateLimit?.window || 60,
432 | max: opts.rateLimit?.max || 5,
433 | },
434 | ],
435 | } satisfies BetterAuthPlugin;
436 | };
437 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/routes/email-verification.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it, vi } from "vitest";
2 | import { getTestInstance } from "../../test-utils/test-instance";
3 |
4 | describe("Email Verification", async () => {
5 | const mockSendEmail = vi.fn();
6 | let token: string;
7 | const { auth, testUser, client, signInWithUser } = await getTestInstance({
8 | emailAndPassword: {
9 | enabled: true,
10 | requireEmailVerification: true,
11 | },
12 | emailVerification: {
13 | async sendVerificationEmail({ user, url, token: _token }) {
14 | token = _token;
15 | mockSendEmail(user.email, url);
16 | },
17 | },
18 | });
19 |
20 | it("should send a verification email when enabled", async () => {
21 | await auth.api.sendVerificationEmail({
22 | body: {
23 | email: testUser.email,
24 | },
25 | });
26 | expect(mockSendEmail).toHaveBeenCalledWith(
27 | testUser.email,
28 | expect.any(String),
29 | );
30 | });
31 |
32 | it("should send a verification email if verification is required and user is not verified", async () => {
33 | await signInWithUser(testUser.email, testUser.password);
34 |
35 | expect(mockSendEmail).toHaveBeenCalledWith(
36 | testUser.email,
37 | expect.any(String),
38 | );
39 | });
40 |
41 | it("should verify email", async () => {
42 | const res = await client.verifyEmail({
43 | query: {
44 | token,
45 | },
46 | });
47 | expect(res.data?.status).toBe(true);
48 | });
49 |
50 | it("should redirect to callback", async () => {
51 | await client.verifyEmail(
52 | {
53 | query: {
54 | token,
55 | callbackURL: "/callback",
56 | },
57 | },
58 | {
59 | onError: (ctx) => {
60 | const location = ctx.response.headers.get("location");
61 | expect(location).toBe("/callback");
62 | },
63 | },
64 | );
65 | });
66 |
67 | it("should sign after verification", async () => {
68 | const { testUser, client, sessionSetter, runWithUser } =
69 | await getTestInstance({
70 | emailAndPassword: {
71 | enabled: true,
72 | requireEmailVerification: true,
73 | },
74 | emailVerification: {
75 | async sendVerificationEmail({ user, url, token: _token }) {
76 | token = _token;
77 | mockSendEmail(user.email, url);
78 | },
79 | autoSignInAfterVerification: true,
80 | },
81 | });
82 |
83 | // Attempt to update user info (should fail before verification)
84 | await runWithUser(testUser.email, testUser.password, async () => {
85 | const updateRes = await client.updateUser({
86 | name: "New Name",
87 | image: "https://example.com/image.jpg",
88 | });
89 | expect(updateRes.data).toBeNull();
90 | expect(updateRes.error!.status).toBe(401);
91 | expect(updateRes.error!.statusText).toBe("UNAUTHORIZED");
92 | });
93 |
94 | let sessionToken = "";
95 | let verifyHeaders = new Headers();
96 | const res = await client.verifyEmail({
97 | query: {
98 | token,
99 | },
100 | fetchOptions: {
101 | onSuccess(context) {
102 | sessionToken = context.response.headers.get("set-auth-token") || "";
103 | sessionSetter(verifyHeaders)(context);
104 | },
105 | },
106 | });
107 | expect(sessionToken.length).toBeGreaterThan(10);
108 | const session = await client.getSession({
109 | fetchOptions: {
110 | headers: verifyHeaders,
111 | throw: true,
112 | },
113 | });
114 | expect(session!.user.emailVerified).toBe(true);
115 | });
116 |
117 | it("should use custom expiresIn", async () => {
118 | const { auth, client } = await getTestInstance({
119 | emailAndPassword: {
120 | enabled: true,
121 | requireEmailVerification: true,
122 | },
123 | emailVerification: {
124 | async sendVerificationEmail({ user, url, token: _token }) {
125 | token = _token;
126 | mockSendEmail(user.email, url);
127 | },
128 | expiresIn: 10,
129 | },
130 | });
131 | await auth.api.sendVerificationEmail({
132 | body: {
133 | email: testUser.email,
134 | },
135 | });
136 | vi.useFakeTimers();
137 | await vi.advanceTimersByTimeAsync(10 * 1000);
138 | const res = await client.verifyEmail({
139 | query: {
140 | token,
141 | },
142 | });
143 | expect(res.error?.code).toBe("TOKEN_EXPIRED");
144 | });
145 |
146 | it("should call onEmailVerification callback when email is verified", async () => {
147 | const onEmailVerificationMock = vi.fn();
148 | const { auth, client } = await getTestInstance({
149 | emailAndPassword: {
150 | enabled: true,
151 | requireEmailVerification: true,
152 | },
153 | emailVerification: {
154 | async sendVerificationEmail({ user, url, token: _token }) {
155 | token = _token;
156 | mockSendEmail(user.email, url);
157 | },
158 | onEmailVerification: onEmailVerificationMock,
159 | },
160 | });
161 |
162 | await auth.api.sendVerificationEmail({
163 | body: {
164 | email: testUser.email,
165 | },
166 | });
167 |
168 | const res = await client.verifyEmail({
169 | query: {
170 | token,
171 | },
172 | });
173 |
174 | expect(res.data?.status).toBe(true);
175 | expect(onEmailVerificationMock).toHaveBeenCalledWith(
176 | expect.objectContaining({ email: testUser.email }),
177 | expect.any(Object),
178 | );
179 | });
180 |
181 | it("should call afterEmailVerification callback when email is verified", async () => {
182 | const afterEmailVerificationMock = vi.fn();
183 | const { auth, client, testUser } = await getTestInstance({
184 | emailAndPassword: {
185 | enabled: true,
186 | requireEmailVerification: true,
187 | },
188 | emailVerification: {
189 | async sendVerificationEmail({ user, url, token: _token }) {
190 | token = _token;
191 | mockSendEmail(user.email, url);
192 | },
193 | afterEmailVerification: afterEmailVerificationMock,
194 | },
195 | });
196 |
197 | await auth.api.sendVerificationEmail({
198 | body: {
199 | email: testUser.email,
200 | },
201 | });
202 |
203 | const res = await client.verifyEmail({
204 | query: {
205 | token,
206 | },
207 | });
208 |
209 | expect(res.data?.status).toBe(true);
210 | expect(afterEmailVerificationMock).toHaveBeenCalledWith(
211 | expect.objectContaining({ email: testUser.email, emailVerified: true }),
212 | expect.any(Object),
213 | );
214 | });
215 |
216 | it("should preserve encoded characters in callback URL", async () => {
217 | const testEmail = "[email protected]";
218 | const encodedEmail = encodeURIComponent(testEmail);
219 | const callbackURL = `/sign-in?verifiedEmail=${encodedEmail}`;
220 |
221 | await client.verifyEmail(
222 | {
223 | query: {
224 | token,
225 | callbackURL,
226 | },
227 | },
228 | {
229 | onError: (ctx) => {
230 | const location = ctx.response.headers.get("location");
231 | expect(location).toBe(`/sign-in?verifiedEmail=${encodedEmail}`);
232 | const url = new URL(location!, "http://localhost:3000");
233 | expect(url.searchParams.get("verifiedEmail")).toBe(testEmail);
234 | },
235 | },
236 | );
237 | });
238 |
239 | it("should properly encode callbackURL with query parameters when sending verification email", async () => {
240 | const mockSendEmailLocal = vi.fn();
241 | let capturedUrl = "";
242 | const { auth, testUser } = await getTestInstance({
243 | emailAndPassword: {
244 | enabled: true,
245 | requireEmailVerification: true,
246 | },
247 | emailVerification: {
248 | async sendVerificationEmail({ user, url, token: _token }) {
249 | capturedUrl = url;
250 | mockSendEmailLocal(user.email, url);
251 | },
252 | },
253 | });
254 |
255 | const callbackURL =
256 | "https://example.com/app?redirect=/dashboard&tab=settings";
257 | await auth.api.sendVerificationEmail({
258 | body: {
259 | email: testUser.email,
260 | callbackURL,
261 | },
262 | });
263 | expect(mockSendEmailLocal).toHaveBeenCalled();
264 |
265 | const emailUrl = new URL(capturedUrl);
266 | const callbackURLParam = emailUrl.searchParams.get("callbackURL");
267 |
268 | expect(callbackURLParam).toBe(callbackURL);
269 | expect(callbackURLParam).toContain("?redirect=/dashboard&tab=settings");
270 | });
271 | });
272 |
273 | describe("Email Verification Secondary Storage", async () => {
274 | let store = new Map<string, string>();
275 | let token: string;
276 | const { client, signInWithTestUser, db, auth, testUser, cookieSetter } =
277 | await getTestInstance({
278 | secondaryStorage: {
279 | set(key, value, ttl) {
280 | store.set(key, value);
281 | },
282 | get(key) {
283 | return store.get(key) || null;
284 | },
285 | delete(key) {
286 | store.delete(key);
287 | },
288 | },
289 | rateLimit: {
290 | enabled: false,
291 | },
292 | emailAndPassword: {
293 | enabled: true,
294 | },
295 | emailVerification: {
296 | async sendVerificationEmail({ user, url, token: _token }) {
297 | token = _token;
298 | },
299 | autoSignInAfterVerification: true,
300 | },
301 | user: {
302 | changeEmail: {
303 | enabled: true,
304 | async sendChangeEmailVerification(data, request) {
305 | token = data.token;
306 | },
307 | },
308 | },
309 | });
310 |
311 | it("should verify email", async () => {
312 | await auth.api.sendVerificationEmail({
313 | body: {
314 | email: testUser.email,
315 | },
316 | });
317 | const headers = new Headers();
318 | await client.verifyEmail({
319 | query: {
320 | token,
321 | },
322 | fetchOptions: {
323 | onSuccess: cookieSetter(headers),
324 | },
325 | });
326 | const session = await client.getSession({
327 | fetchOptions: {
328 | headers,
329 | },
330 | });
331 | expect(session.data?.user.email).toBe(testUser.email);
332 | expect(session.data?.user.emailVerified).toBe(true);
333 | });
334 |
335 | it("should change email", async () => {
336 | const { runWithUser } = await signInWithTestUser();
337 | await runWithUser(async (headers) => {
338 | await auth.api.changeEmail({
339 | body: {
340 | newEmail: "[email protected]",
341 | },
342 | headers,
343 | });
344 | const newHeaders = new Headers();
345 | await client.verifyEmail({
346 | query: {
347 | token,
348 | },
349 | fetchOptions: {
350 | onSuccess: cookieSetter(newHeaders),
351 | headers,
352 | },
353 | });
354 | const session = await client.getSession({
355 | fetchOptions: {
356 | headers: newHeaders,
357 | },
358 | });
359 | expect(session.data?.user.email).toBe("[email protected]");
360 | expect(session.data?.user.emailVerified).toBe(false);
361 | });
362 | });
363 |
364 | it("should set emailVerified on all sessions", async () => {
365 | const sampleUser = {
366 | name: "sampler",
367 | email: "[email protected]",
368 | password: "samplesssss",
369 | };
370 |
371 | await client.signUp.email({
372 | name: sampleUser.name,
373 | email: sampleUser.email,
374 | password: sampleUser.password,
375 | });
376 |
377 | const secondSignInHeaders = new Headers();
378 | await client.signIn.email(
379 | {
380 | email: sampleUser.email,
381 | password: sampleUser.password,
382 | },
383 | {
384 | onSuccess: cookieSetter(secondSignInHeaders),
385 | },
386 | );
387 |
388 | await auth.api.sendVerificationEmail({
389 | body: {
390 | email: sampleUser.email,
391 | },
392 | });
393 |
394 | const headers = new Headers();
395 | await client.verifyEmail({
396 | query: {
397 | token,
398 | },
399 | fetchOptions: {
400 | onSuccess: cookieSetter(headers),
401 | },
402 | });
403 |
404 | const session = await client.getSession({
405 | fetchOptions: {
406 | headers,
407 | },
408 | });
409 |
410 | expect(session.data?.user.email).toBe(sampleUser.email);
411 | expect(session.data?.user.emailVerified).toBe(true);
412 |
413 | const secondSignInSession = await client.getSession({
414 | fetchOptions: {
415 | headers: secondSignInHeaders,
416 | },
417 | });
418 |
419 | expect(secondSignInSession.data?.user.email).toBe(sampleUser.email);
420 | expect(secondSignInSession.data?.user.emailVerified).toBe(true);
421 | });
422 |
423 | it("should set emailVerified on all sessions", async () => {
424 | const sampleUser = {
425 | name: "sampler",
426 | email: "[email protected]",
427 | password: "samplesssss",
428 | };
429 |
430 | await client.signUp.email({
431 | name: sampleUser.name,
432 | email: sampleUser.email,
433 | password: sampleUser.password,
434 | });
435 |
436 | const secondSignInHeaders = new Headers();
437 | await client.signIn.email(
438 | {
439 | email: sampleUser.email,
440 | password: sampleUser.password,
441 | },
442 | {
443 | onSuccess: cookieSetter(secondSignInHeaders),
444 | },
445 | );
446 |
447 | await auth.api.sendVerificationEmail({
448 | body: {
449 | email: sampleUser.email,
450 | },
451 | });
452 |
453 | const headers = new Headers();
454 | await client.verifyEmail({
455 | query: {
456 | token,
457 | },
458 | fetchOptions: {
459 | onSuccess: cookieSetter(headers),
460 | },
461 | });
462 |
463 | const session = await client.getSession({
464 | fetchOptions: {
465 | headers,
466 | },
467 | });
468 |
469 | expect(session.data?.user.email).toBe(sampleUser.email);
470 | expect(session.data?.user.emailVerified).toBe(true);
471 |
472 | const secondSignInSession = await client.getSession({
473 | fetchOptions: {
474 | headers: secondSignInHeaders,
475 | },
476 | });
477 |
478 | expect(secondSignInSession.data?.user.email).toBe(sampleUser.email);
479 | expect(secondSignInSession.data?.user.emailVerified).toBe(true);
480 | });
481 | });
482 |
```
--------------------------------------------------------------------------------
/docs/content/docs/plugins/jwt.mdx:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: JWT
3 | description: Authenticate users with JWT tokens in services that can't use the session
4 | ---
5 |
6 | The JWT plugin provides endpoints to retrieve a JWT token and a JWKS endpoint to verify the token.
7 |
8 | <Callout type="info">
9 | This plugin is not meant as a replacement for the session. It's meant to be used for services that require JWT tokens. If you're looking to use JWT tokens for authentication, check out the [Bearer Plugin](/docs/plugins/bearer).
10 | </Callout>
11 |
12 | ## Installation
13 |
14 | <Steps>
15 | <Step>
16 | ### Add the plugin to your **auth** config
17 | ```ts title="auth.ts"
18 | import { betterAuth } from "better-auth"
19 | import { jwt } from "better-auth/plugins"
20 |
21 | export const auth = betterAuth({
22 | plugins: [ // [!code highlight]
23 | jwt(), // [!code highlight]
24 | ] // [!code highlight]
25 | })
26 | ```
27 | </Step>
28 |
29 | <Step>
30 | ### Migrate the database
31 |
32 | Run the migration or generate the schema to add the necessary fields and tables to the database.
33 |
34 | <Tabs items={["migrate", "generate"]}>
35 | <Tab value="migrate">
36 | ```bash
37 | npx @better-auth/cli migrate
38 | ```
39 | </Tab>
40 | <Tab value="generate">
41 | ```bash
42 | npx @better-auth/cli generate
43 | ```
44 | </Tab>
45 | </Tabs>
46 | See the [Schema](#schema) section to add the fields manually.
47 | </Step>
48 | </Steps>
49 |
50 |
51 | ## Usage
52 |
53 | Once you've installed the plugin, you can start using the JWT & JWKS plugin to get the token and the JWKS through their respective endpoints.
54 |
55 | ## JWT
56 |
57 | ### Retrieve the token
58 |
59 | There are multiple ways to retrieve JWT tokens:
60 |
61 | 1. **Using the client plugin (recommended)**
62 |
63 | Add the `jwtClient` plugin to your auth client configuration:
64 |
65 | ```ts title="auth-client.ts"
66 | import { createAuthClient } from "better-auth/client"
67 | import { jwtClient } from "better-auth/client/plugins" // [!code highlight]
68 |
69 | export const authClient = createAuthClient({
70 | plugins: [
71 | jwtClient() // [!code highlight]
72 | ]
73 | })
74 | ```
75 |
76 | Then use the client to get JWT tokens:
77 |
78 | ```ts
79 | const { data, error } = await authClient.token()
80 | if (error) {
81 | // handle error
82 | }
83 | if (data) {
84 | const jwtToken = data.token
85 | // Use this token for authenticated requests to external services
86 | }
87 | ```
88 |
89 | This is the recommended approach for client applications that need JWT tokens for external API authentication.
90 |
91 | 2. **Using your session token**
92 |
93 | To get the token, call the `/token` endpoint. This will return the following:
94 |
95 | ```json
96 | {
97 | "token": "ey..."
98 | }
99 | ```
100 |
101 | Make sure to include the token in the `Authorization` header of your requests if the `bearer` plugin is added in your auth configuration.
102 |
103 | ```ts
104 | await fetch("/api/auth/token", {
105 | headers: {
106 | "Authorization": `Bearer ${token}`
107 | },
108 | })
109 | ```
110 |
111 | 3. **From `set-auth-jwt` header**
112 |
113 | When you call `getSession` method, a JWT is returned in the `set-auth-jwt` header, which you can use to send to your services directly.
114 |
115 | ```ts
116 | await authClient.getSession({
117 | fetchOptions: {
118 | onSuccess: (ctx)=>{
119 | const jwt = ctx.response.headers.get("set-auth-jwt")
120 | }
121 | }
122 | })
123 | ```
124 |
125 | ### Verifying the token
126 | The token can be verified in your own service, without the need for an additional verify call or database check.
127 | For this JWKS is used. The public key can be fetched from the `/api/auth/jwks` endpoint.
128 |
129 | Since this key is not subject to frequent changes, it can be cached indefinitely.
130 | The key ID (`kid`) that was used to sign a JWT is included in the header of the token.
131 | In case a JWT with a different `kid` is received, it is recommended to fetch the JWKS again.
132 |
133 | ```json
134 | {
135 | "keys": [
136 | {
137 | "crv": "Ed25519",
138 | "x": "bDHiLTt7u-VIU7rfmcltcFhaHKLVvWFy-_csKZARUEU",
139 | "kty": "OKP",
140 | "kid": "c5c7995d-0037-4553-8aee-b5b620b89b23"
141 | }
142 | ]
143 | }
144 | ```
145 |
146 | ### OAuth Provider Mode
147 |
148 | If you are making your system oAuth compliant (such as when utilizing the OIDC or MCP plugins), you **MUST** disable the `/token` endpoint (oAuth equivalent `/oauth2/token`) and disable setting the jwt header (oAuth equivalent `/oauth2/userinfo`).
149 |
150 | ```ts title="auth.ts"
151 | betterAuth({
152 | disabledPaths: [
153 | "/token",
154 | ],
155 | plugins: [jwt({
156 | disableSettingJwtHeader: true,
157 | })]
158 | })
159 | ```
160 |
161 | #### Example using jose with remote JWKS
162 |
163 | ```ts
164 | import { jwtVerify, createRemoteJWKSet } from 'jose'
165 |
166 | async function validateToken(token: string) {
167 | try {
168 | const JWKS = createRemoteJWKSet(
169 | new URL('http://localhost:3000/api/auth/jwks')
170 | )
171 | const { payload } = await jwtVerify(token, JWKS, {
172 | issuer: 'http://localhost:3000', // Should match your JWT issuer, which is the BASE_URL
173 | audience: 'http://localhost:3000', // Should match your JWT audience, which is the BASE_URL by default
174 | })
175 | return payload
176 | } catch (error) {
177 | console.error('Token validation failed:', error)
178 | throw error
179 | }
180 | }
181 |
182 | // Usage example
183 | const token = 'your.jwt.token' // this is the token you get from the /api/auth/token endpoint
184 | const payload = await validateToken(token)
185 | ```
186 |
187 | #### Example with local JWKS
188 |
189 | ```ts
190 | import { jwtVerify, createLocalJWKSet } from 'jose'
191 |
192 |
193 | async function validateToken(token: string) {
194 | try {
195 | /**
196 | * This is the JWKS that you get from the /api/auth/
197 | * jwks endpoint
198 | */
199 | const storedJWKS = {
200 | keys: [{
201 | //...
202 | }]
203 | };
204 | const JWKS = createLocalJWKSet({
205 | keys: storedJWKS.data?.keys!,
206 | })
207 | const { payload } = await jwtVerify(token, JWKS, {
208 | issuer: 'http://localhost:3000', // Should match your JWT issuer, which is the BASE_URL
209 | audience: 'http://localhost:3000', // Should match your JWT audience, which is the BASE_URL by default
210 | })
211 | return payload
212 | } catch (error) {
213 | console.error('Token validation failed:', error)
214 | throw error
215 | }
216 | }
217 |
218 | // Usage example
219 | const token = 'your.jwt.token' // this is the token you get from the /api/auth/token endpoint
220 | const payload = await validateToken(token)
221 | ```
222 |
223 | ### Remote JWKS Url
224 |
225 | Disables the `/jwks` endpoint and uses this endpoint in any discovery such as OIDC.
226 |
227 | Useful if your JWKS are not managed at `/jwks` or if your jwks are signed with a certificate and placed on your CDN.
228 |
229 | NOTE: you **MUST** specify which asymmetric algorithm is used for signing.
230 |
231 | ```ts title="auth.ts"
232 | jwt({
233 | jwks: {
234 | remoteUrl: "https://example.com/.well-known/jwks.json",
235 | keyPairConfig: {
236 | alg: 'ES256',
237 | },
238 | }
239 | })
240 | ```
241 |
242 | ### Custom Signing
243 |
244 | This is an advanced feature. Configuration outside of this plugin **MUST** be provided.
245 |
246 | Implementers:
247 | - `remoteUrl` must be defined if using the `sign` function. This shall store all active keys, not just the current one.
248 | - If using localized approach, ensure server uses the latest private key when rotated. Depending on deployment, the server may need to be restarted.
249 | - When using remote approach, verify the payload is unchanged after transit. Use integrity validation like CRC32 or SHA256 checks if available.
250 |
251 | #### Localized Signing
252 |
253 | ```ts title="auth.ts"
254 | jwt({
255 | jwks: {
256 | remoteUrl: "https://example.com/.well-known/jwks.json",
257 | keyPairConfig: {
258 | alg: 'EdDSA',
259 | },
260 | },
261 | jwt: {
262 | sign: async (jwtPayload: JWTPayload) => {
263 | // this is pseudocode
264 | return await new SignJWT(jwtPayload)
265 | .setProtectedHeader({
266 | alg: "EdDSA",
267 | kid: process.env.currentKid,
268 | typ: "JWT",
269 | })
270 | .sign(process.env.clientPrivateKey);
271 | },
272 | },
273 | })
274 | ```
275 |
276 | #### Remote Signing
277 |
278 | Useful if you are using a remote Key Management Service such as [Google KMS](https://cloud.google.com/kms/docs/encrypt-decrypt-rsa#kms-encrypt-asymmetric-nodejs), [Amazon KMS](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html), or [Azure Key Vault](https://learn.microsoft.com/en-us/rest/api/keyvault/keys/sign/sign?view=rest-keyvault-keys-7.4&tabs=HTTP).
279 |
280 | ```ts title="auth.ts"
281 | jwt({
282 | jwks: {
283 | remoteUrl: "https://example.com/.well-known/jwks.json",
284 | keyPairConfig: {
285 | alg: 'ES256',
286 | },
287 | },
288 | jwt: {
289 | sign: async (jwtPayload: JWTPayload) => {
290 | // this is pseudocode
291 | const headers = JSON.stringify({ kid: '123', alg: 'ES256', typ: 'JWT' })
292 | const payload = JSON.stringify(jwtPayload)
293 | const encodedHeaders = Buffer.from(headers).toString('base64url')
294 | const encodedPayload = Buffer.from(payload).toString('base64url')
295 | const hash = createHash('sha256')
296 | const data = `${encodedHeaders}.${encodedPayload}`
297 | hash.update(Buffer.from(data))
298 | const digest = hash.digest()
299 | const sig = await remoteSign(digest)
300 | // integrityCheck(sig)
301 | const jwt = `${data}.${sig}`
302 | // verifyJwt(jwt)
303 | return jwt
304 | },
305 | },
306 | })
307 | ```
308 |
309 |
310 | ## Schema
311 |
312 | The JWT plugin adds the following tables to the database:
313 |
314 | ### JWKS
315 |
316 | Table Name: `jwks`
317 |
318 | <DatabaseTable
319 | fields={[
320 | {
321 | name: "id",
322 | type: "string",
323 | description: "Unique identifier for each web key",
324 | isPrimaryKey: true
325 | },
326 | {
327 | name: "publicKey",
328 | type: "string",
329 | description: "The public part of the web key"
330 | },
331 | {
332 | name: "privateKey",
333 | type: "string",
334 | description: "The private part of the web key"
335 | },
336 | {
337 | name: "createdAt",
338 | type: "Date",
339 | description: "Timestamp of when the web key was created"
340 | },
341 | ]}
342 | />
343 |
344 | <Callout>
345 | You can customize the table name and fields for the `jwks` table. See the [Database concept documentation](/docs/concepts/database#custom-table-names) for more information on how to customize plugin schema.
346 | </Callout>
347 |
348 | ## Options
349 |
350 | ### Algorithm of the Key Pair
351 |
352 | The algorithm used for the generation of the key pair. The default is **EdDSA** with the **Ed25519** curve. Below are the available options:
353 |
354 | ```ts title="auth.ts"
355 | jwt({
356 | jwks: {
357 | keyPairConfig: {
358 | alg: "EdDSA",
359 | crv: "Ed25519"
360 | }
361 | }
362 | })
363 | ```
364 |
365 | #### EdDSA
366 | - **Default Curve**: `Ed25519`
367 | - **Optional Property**: `crv`
368 | - Available options: `Ed25519`, `Ed448`
369 | - Default: `Ed25519`
370 |
371 | #### ES256
372 | - No additional properties
373 |
374 | #### RSA256
375 | - **Optional Property**: `modulusLength`
376 | - Expects a number
377 | - Default: `2048`
378 |
379 | #### PS256
380 | - **Optional Property**: `modulusLength`
381 | - Expects a number
382 | - Default: `2048`
383 |
384 | #### ECDH-ES
385 | - **Optional Property**: `crv`
386 | - Available options: `P-256`, `P-384`, `P-521`
387 | - Default: `P-256`
388 |
389 | #### ES512
390 | - No additional properties
391 |
392 |
393 | ### Disable private key encryption
394 |
395 | By default, the private key is encrypted using AES256 GCM. You can disable this by setting the `disablePrivateKeyEncryption` option to `true`.
396 |
397 | For security reasons, it's recommended to keep the private key encrypted.
398 |
399 | ```ts title="auth.ts"
400 | jwt({
401 | jwks: {
402 | disablePrivateKeyEncryption: true
403 | }
404 | })
405 | ```
406 |
407 | ### Modify JWT payload
408 |
409 | By default the entire user object is added to the JWT payload. You can modify the payload by providing a function to the `definePayload` option.
410 |
411 | ```ts title="auth.ts"
412 | jwt({
413 | jwt: {
414 | definePayload: ({user}) => {
415 | return {
416 | id: user.id,
417 | email: user.email,
418 | role: user.role
419 | }
420 | }
421 | }
422 | })
423 | ```
424 |
425 | ### Modify Issuer, Audience, Subject or Expiration time
426 | If none is given, the `BASE_URL` is used as the issuer and the audience is set to the `BASE_URL`. The expiration time is set to 15 minutes.
427 |
428 | ```ts title="auth.ts"
429 | jwt({
430 | jwt: {
431 | issuer: "https://example.com",
432 | audience: "https://example.com",
433 | expirationTime: "1h",
434 | getSubject: (session) => {
435 | // by default the subject is the user id
436 | return session.user.email
437 | }
438 | }
439 | })
440 | ```
441 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/routes/account.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | afterAll,
3 | afterEach,
4 | beforeAll,
5 | describe,
6 | expect,
7 | it,
8 | vi,
9 | type MockInstance,
10 | } from "vitest";
11 | import { setupServer } from "msw/node";
12 | import { http, HttpResponse } from "msw";
13 | import { getTestInstance } from "../../test-utils/test-instance";
14 | import { parseSetCookieHeader } from "../../cookies";
15 | import type { GoogleProfile } from "@better-auth/core/social-providers";
16 | import { DEFAULT_SECRET } from "../../utils/constants";
17 | import { signJWT } from "../../crypto";
18 | import { BASE_ERROR_CODES } from "@better-auth/core/error";
19 | import type { Account } from "../../types";
20 |
21 | let email = "";
22 | let handlers: ReturnType<typeof http.post>[];
23 |
24 | const server = setupServer();
25 |
26 | beforeAll(async () => {
27 | handlers = [
28 | http.post("https://oauth2.googleapis.com/token", async () => {
29 | const data: GoogleProfile = {
30 | email,
31 | email_verified: true,
32 | name: "First Last",
33 | picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw",
34 | exp: 1234567890,
35 | sub: "1234567890",
36 | iat: 1234567890,
37 | aud: "test",
38 | azp: "test",
39 | nbf: 1234567890,
40 | iss: "test",
41 | locale: "en",
42 | jti: "test",
43 | given_name: "First",
44 | family_name: "Last",
45 | };
46 | const testIdToken = await signJWT(data, DEFAULT_SECRET);
47 | return HttpResponse.json({
48 | access_token: "test",
49 | refresh_token: "test",
50 | id_token: testIdToken,
51 | });
52 | }),
53 | ];
54 |
55 | server.listen({ onUnhandledRequest: "bypass" });
56 | server.use(...handlers);
57 | });
58 |
59 | afterEach(() => {
60 | server.resetHandlers();
61 | server.use(...handlers);
62 | });
63 |
64 | afterAll(() => server.close());
65 |
66 | describe("account", async () => {
67 | const { auth, signInWithTestUser, client } = await getTestInstance({
68 | socialProviders: {
69 | google: {
70 | clientId: "test",
71 | clientSecret: "test",
72 | enabled: true,
73 | },
74 | },
75 | account: {
76 | accountLinking: {
77 | allowDifferentEmails: true,
78 | },
79 | encryptOAuthTokens: true,
80 | },
81 | });
82 |
83 | const ctx = await auth.$context;
84 |
85 | let googleVerifyIdTokenMock: MockInstance;
86 | let googleGetUserInfoMock: MockInstance;
87 | beforeAll(() => {
88 | const googleProvider = ctx.socialProviders.find((v) => v.id === "google")!;
89 | expect(googleProvider).toBeTruthy();
90 |
91 | googleVerifyIdTokenMock = vi.spyOn(googleProvider, "verifyIdToken");
92 | googleGetUserInfoMock = vi.spyOn(googleProvider, "getUserInfo");
93 | });
94 | afterEach(() => {
95 | googleVerifyIdTokenMock.mockClear();
96 | googleGetUserInfoMock.mockClear();
97 | });
98 |
99 | const { runWithUser } = await signInWithTestUser();
100 |
101 | it("should list all accounts", async () => {
102 | await runWithUser(async () => {
103 | const accounts = await client.listAccounts();
104 | expect(accounts.data?.length).toBe(1);
105 | });
106 | });
107 |
108 | it("should link first account", async () => {
109 | await runWithUser(async (headers) => {
110 | const linkAccountRes = await client.linkSocial(
111 | {
112 | provider: "google",
113 | callbackURL: "/callback",
114 | },
115 | {
116 | onSuccess(context) {
117 | const cookies = parseSetCookieHeader(
118 | context.response.headers.get("set-cookie") || "",
119 | );
120 | headers.set(
121 | "cookie",
122 | `better-auth.state=${cookies.get("better-auth.state")?.value}`,
123 | );
124 | },
125 | },
126 | );
127 | expect(linkAccountRes.data).toMatchObject({
128 | url: expect.stringContaining("google.com"),
129 | redirect: true,
130 | });
131 | const state =
132 | linkAccountRes.data && "url" in linkAccountRes.data
133 | ? new URL(linkAccountRes.data.url).searchParams.get("state") || ""
134 | : "";
135 | email = "[email protected]";
136 | await client.$fetch("/callback/google", {
137 | query: {
138 | state,
139 | code: "test",
140 | },
141 | method: "GET",
142 | onError(context) {
143 | expect(context.response.status).toBe(302);
144 | const location = context.response.headers.get("location");
145 | expect(location).toBeDefined();
146 | expect(location).toContain("/callback");
147 | },
148 | });
149 | });
150 | const { runWithUser: runWithClient2 } = await signInWithTestUser();
151 | await runWithClient2(async () => {
152 | const accounts = await client.listAccounts();
153 | expect(accounts.data?.length).toBe(2);
154 | });
155 | });
156 |
157 | it("should encrypt access token and refresh token", async () => {
158 | const { runWithUser: runWithClient2 } = await signInWithTestUser();
159 | const account = await ctx.adapter.findOne<Account>({
160 | model: "account",
161 | where: [{ field: "providerId", value: "google" }],
162 | });
163 | expect(account).toBeTruthy();
164 | expect(account?.accessToken).not.toBe("test");
165 | await runWithClient2(async () => {
166 | const accessToken = await client.getAccessToken({
167 | providerId: "google",
168 | });
169 | expect(accessToken.data?.accessToken).toBe("test");
170 | });
171 | });
172 |
173 | it("should pass custom scopes to authorization URL", async () => {
174 | const { runWithUser: runWithClient2 } = await signInWithTestUser();
175 | await runWithClient2(async () => {
176 | const customScope = "https://www.googleapis.com/auth/drive.readonly";
177 | const linkAccountRes = await client.linkSocial({
178 | provider: "google",
179 | callbackURL: "/callback",
180 | scopes: [customScope],
181 | });
182 |
183 | expect(linkAccountRes.data).toMatchObject({
184 | url: expect.stringContaining("google.com"),
185 | redirect: true,
186 | });
187 |
188 | const url =
189 | linkAccountRes.data && "url" in linkAccountRes.data
190 | ? new URL(linkAccountRes.data.url)
191 | : new URL("");
192 | const scopesParam = url.searchParams.get("scope");
193 | expect(scopesParam).toContain(customScope);
194 | });
195 | });
196 |
197 | it("should link second account from the same provider", async () => {
198 | const { runWithUser: runWithClient2 } = await signInWithTestUser();
199 | await runWithClient2(async (headers) => {
200 | const linkAccountRes = await client.linkSocial(
201 | {
202 | provider: "google",
203 | callbackURL: "/callback",
204 | },
205 | {
206 | onSuccess(context) {
207 | const cookies = parseSetCookieHeader(
208 | context.response.headers.get("set-cookie") || "",
209 | );
210 | headers.set(
211 | "cookie",
212 | `better-auth.state=${cookies.get("better-auth.state")?.value}`,
213 | );
214 | },
215 | },
216 | );
217 | expect(linkAccountRes.data).toMatchObject({
218 | url: expect.stringContaining("google.com"),
219 | redirect: true,
220 | });
221 | const state =
222 | linkAccountRes.data && "url" in linkAccountRes.data
223 | ? new URL(linkAccountRes.data.url).searchParams.get("state") || ""
224 | : "";
225 | email = "[email protected]";
226 | await client.$fetch("/callback/google", {
227 | query: {
228 | state,
229 | code: "test",
230 | },
231 | method: "GET",
232 | onError(context) {
233 | expect(context.response.status).toBe(302);
234 | const location = context.response.headers.get("location");
235 | expect(location).toBeDefined();
236 | expect(location).toContain("/callback");
237 | },
238 | });
239 | });
240 |
241 | const { runWithUser: runWithClient3 } = await signInWithTestUser();
242 | await runWithClient3(async () => {
243 | const accounts = await client.listAccounts();
244 | expect(accounts.data?.length).toBe(2);
245 | });
246 | });
247 |
248 | it("should link third account with idToken", async () => {
249 | googleVerifyIdTokenMock.mockResolvedValueOnce(true);
250 | const user = {
251 | id: "0987654321",
252 | name: "test2",
253 | email: "[email protected]",
254 | sub: "test2",
255 | emailVerified: true,
256 | };
257 | const userInfo = {
258 | user,
259 | data: user,
260 | };
261 | googleGetUserInfoMock.mockResolvedValueOnce(userInfo);
262 |
263 | const { runWithUser: runWithClient2 } = await signInWithTestUser();
264 | await runWithClient2(async (headers) => {
265 | await client.linkSocial(
266 | {
267 | provider: "google",
268 | callbackURL: "/callback",
269 | idToken: { token: "test" },
270 | },
271 | {
272 | onSuccess(context) {
273 | const cookies = parseSetCookieHeader(
274 | context.response.headers.get("set-cookie") || "",
275 | );
276 | headers.set(
277 | "cookie",
278 | `better-auth.state=${cookies.get("better-auth.state")?.value}`,
279 | );
280 | },
281 | },
282 | );
283 | });
284 |
285 | expect(googleVerifyIdTokenMock).toHaveBeenCalledOnce();
286 | expect(googleGetUserInfoMock).toHaveBeenCalledOnce();
287 |
288 | const { runWithUser: runWithClient3 } = await signInWithTestUser();
289 | await runWithClient3(async () => {
290 | const accounts = await client.listAccounts();
291 | expect(accounts.data?.length).toBe(3);
292 | });
293 | });
294 |
295 | it("should unlink account", async () => {
296 | const { runWithUser } = await signInWithTestUser();
297 | await runWithUser(async () => {
298 | const previousAccounts = await client.listAccounts();
299 | expect(previousAccounts.data?.length).toBe(3);
300 | const unlinkAccountId = previousAccounts.data![1]!.accountId;
301 | const unlinkRes = await client.unlinkAccount({
302 | providerId: "google",
303 | accountId: unlinkAccountId!,
304 | });
305 | expect(unlinkRes.data?.status).toBe(true);
306 | const accounts = await client.listAccounts();
307 | expect(accounts.data?.length).toBe(2);
308 | });
309 | });
310 |
311 | it("should fail to unlink the last account of a provider", async () => {
312 | const { runWithUser } = await signInWithTestUser();
313 | await runWithUser(async () => {
314 | const previousAccounts = await client.listAccounts();
315 | await ctx.adapter.delete({
316 | model: "account",
317 | where: [
318 | {
319 | field: "providerId",
320 | value: "google",
321 | },
322 | ],
323 | });
324 | const unlinkAccountId = previousAccounts.data![0]!.accountId;
325 | const unlinkRes = await client.unlinkAccount({
326 | providerId: "credential",
327 | accountId: unlinkAccountId,
328 | });
329 | expect(unlinkRes.error?.message).toBe(
330 | BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT,
331 | );
332 | });
333 | });
334 |
335 | it("should unlink account with specific accountId", async () => {
336 | const { runWithUser } = await signInWithTestUser();
337 | await runWithUser(async () => {
338 | const previousAccounts = await client.listAccounts();
339 | expect(previousAccounts.data?.length).toBeGreaterThan(0);
340 |
341 | const accountToUnlink = previousAccounts.data![0]!;
342 | const unlinkAccountId = accountToUnlink.accountId;
343 | const providerId = accountToUnlink.providerId;
344 | const accountsWithSameProvider = previousAccounts.data!.filter(
345 | (account) => account.providerId === providerId,
346 | );
347 | if (accountsWithSameProvider.length <= 1) {
348 | return;
349 | }
350 |
351 | const unlinkRes = await client.unlinkAccount({
352 | providerId,
353 | accountId: unlinkAccountId!,
354 | });
355 |
356 | expect(unlinkRes.data?.status).toBe(true);
357 |
358 | const accountsAfterUnlink = await client.listAccounts();
359 |
360 | expect(accountsAfterUnlink.data?.length).toBe(
361 | previousAccounts.data!.length - 1,
362 | );
363 | expect(
364 | accountsAfterUnlink.data?.find((a) => a.accountId === unlinkAccountId),
365 | ).toBeUndefined();
366 | });
367 | });
368 |
369 | it("should unlink all accounts with specific providerId", async () => {
370 | const { runWithUser, user } = await signInWithTestUser();
371 | await ctx.adapter.create({
372 | model: "account",
373 | data: {
374 | providerId: "google",
375 | accountId: "123",
376 | userId: user.id,
377 | createdAt: new Date(),
378 | updatedAt: new Date(),
379 | },
380 | });
381 |
382 | await ctx.adapter.create({
383 | model: "account",
384 | data: {
385 | providerId: "google",
386 | accountId: "345",
387 | userId: user.id,
388 | createdAt: new Date(),
389 | updatedAt: new Date(),
390 | },
391 | });
392 |
393 | await runWithUser(async () => {
394 | const previousAccounts = await client.listAccounts();
395 |
396 | const googleAccounts = previousAccounts.data!.filter(
397 | (account) => account.providerId === "google",
398 | );
399 | expect(googleAccounts.length).toBeGreaterThan(1);
400 |
401 | for (let i = 0; i < googleAccounts.length - 1; i++) {
402 | const unlinkRes = await client.unlinkAccount({
403 | providerId: "google",
404 | accountId: googleAccounts[i]!.accountId!,
405 | });
406 | expect(unlinkRes.data?.status).toBe(true);
407 | }
408 |
409 | const accountsAfterUnlink = await client.listAccounts();
410 |
411 | const remainingGoogleAccounts = accountsAfterUnlink.data!.filter(
412 | (account) => account.providerId === "google",
413 | );
414 | expect(remainingGoogleAccounts.length).toBe(1);
415 | });
416 | });
417 | });
418 |
```
--------------------------------------------------------------------------------
/docs/content/docs/plugins/last-login-method.mdx:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Last Login Method
3 | description: Track and display the last authentication method used by users
4 | ---
5 |
6 | The last login method plugin tracks the most recent authentication method used by users (email, OAuth providers, etc.). This enables you to display helpful indicators on login pages, such as "Last signed in with Google" or prioritize certain login methods based on user preferences.
7 |
8 | ## Installation
9 |
10 | <Steps>
11 | <Step>
12 | ### Add the plugin to your auth config
13 |
14 | ```ts title="auth.ts"
15 | import { betterAuth } from "better-auth"
16 | import { lastLoginMethod } from "better-auth/plugins" // [!code highlight]
17 |
18 | export const auth = betterAuth({
19 | // ... other config options
20 | plugins: [
21 | lastLoginMethod() // [!code highlight]
22 | ]
23 | })
24 | ```
25 | </Step>
26 | <Step>
27 | ### Add the client plugin to your auth client
28 |
29 | ```ts title="auth-client.ts"
30 | import { createAuthClient } from "better-auth/client"
31 | import { lastLoginMethodClient } from "better-auth/client/plugins" // [!code highlight]
32 |
33 | export const authClient = createAuthClient({
34 | plugins: [
35 | lastLoginMethodClient() // [!code highlight]
36 | ]
37 | })
38 | ```
39 | </Step>
40 | </Steps>
41 |
42 | ## Usage
43 |
44 | Once installed, the plugin automatically tracks the last authentication method used by users. You can then retrieve and display this information in your application.
45 |
46 | ### Getting the Last Used Method
47 |
48 | The client plugin provides several methods to work with the last login method:
49 |
50 | ```ts title="app.tsx"
51 | import { authClient } from "@/lib/auth-client"
52 |
53 | // Get the last used login method
54 | const lastMethod = authClient.getLastUsedLoginMethod()
55 | console.log(lastMethod) // "google", "email", "github", etc.
56 |
57 | // Check if a specific method was last used
58 | const wasGoogle = authClient.isLastUsedLoginMethod("google")
59 |
60 | // Clear the stored method
61 | authClient.clearLastUsedLoginMethod()
62 | ```
63 |
64 | ### UI Integration Example
65 |
66 | Here's how to use the plugin to enhance your login page:
67 |
68 | ```tsx title="sign-in.tsx"
69 | import { authClient } from "@/lib/auth-client"
70 | import { Button } from "@/components/ui/button"
71 | import { Badge } from "@/components/ui/badge"
72 |
73 | export function SignInPage() {
74 | const lastMethod = authClient.getLastUsedLoginMethod()
75 |
76 | return (
77 | <div className="space-y-4">
78 | <h1>Sign In</h1>
79 |
80 | {/* Email sign in */}
81 | <div className="relative">
82 | <Button
83 | onClick={() => authClient.signIn.email({...})}
84 | variant={lastMethod === "email" ? "default" : "outline"}
85 | className="w-full"
86 | >
87 | Sign in with Email
88 | {lastMethod === "email" && (
89 | <Badge className="ml-2">Last used</Badge>
90 | )}
91 | </Button>
92 | </div>
93 |
94 | {/* OAuth providers */}
95 | <div className="relative">
96 | <Button
97 | onClick={() => authClient.signIn.social({ provider: "google" })}
98 | variant={lastMethod === "google" ? "default" : "outline"}
99 | className="w-full"
100 | >
101 | Continue with Google
102 | {lastMethod === "google" && (
103 | <Badge className="ml-2">Last used</Badge>
104 | )}
105 | </Button>
106 | </div>
107 |
108 | <div className="relative">
109 | <Button
110 | onClick={() => authClient.signIn.social({ provider: "github" })}
111 | variant={lastMethod === "github" ? "default" : "outline"}
112 | className="w-full"
113 | >
114 | Continue with GitHub
115 | {lastMethod === "github" && (
116 | <Badge className="ml-2">Last used</Badge>
117 | )}
118 | </Button>
119 | </div>
120 | </div>
121 | )
122 | }
123 | ```
124 |
125 | ## Database Persistence
126 |
127 | By default, the last login method is stored only in cookies. For more persistent tracking and analytics, you can enable database storage.
128 |
129 | <Steps>
130 | <Step>
131 | ### Enable database storage
132 |
133 | Set `storeInDatabase` to `true` in your plugin configuration:
134 |
135 | ```ts title="auth.ts"
136 | import { betterAuth } from "better-auth"
137 | import { lastLoginMethod } from "better-auth/plugins"
138 |
139 | export const auth = betterAuth({
140 | plugins: [
141 | lastLoginMethod({
142 | storeInDatabase: true // [!code highlight]
143 | })
144 | ]
145 | })
146 | ```
147 | </Step>
148 | <Step>
149 | ### Run database migration
150 |
151 | The plugin will automatically add a `lastLoginMethod` field to your user table. Run the migration to apply the changes:
152 |
153 | <Tabs items={["migrate", "generate"]}>
154 | <Tab value="migrate">
155 | ```bash
156 | npx @better-auth/cli migrate
157 | ```
158 | </Tab>
159 | <Tab value="generate">
160 | ```bash
161 | npx @better-auth/cli generate
162 | ```
163 | </Tab>
164 | </Tabs>
165 | </Step>
166 | <Step>
167 | ### Access database field
168 |
169 | When database storage is enabled, the `lastLoginMethod` field becomes available in user objects:
170 |
171 | ```ts title="user-profile.tsx"
172 | import { auth } from "@/lib/auth"
173 |
174 | // Server-side access
175 | const session = await auth.api.getSession({ headers })
176 | console.log(session?.user.lastLoginMethod) // "google", "email", etc.
177 |
178 | // Client-side access via session
179 | const { data: session } = authClient.useSession()
180 | console.log(session?.user.lastLoginMethod)
181 | ```
182 | </Step>
183 | </Steps>
184 |
185 | ### Database Schema
186 |
187 | When `storeInDatabase` is enabled, the plugin adds the following field to the `user` table:
188 |
189 | Table: `user`
190 |
191 | <DatabaseTable
192 | fields={[
193 | { name: "lastLoginMethod", type: "string", description: "The last authentication method used by the user", isOptional: true },
194 | ]}
195 | />
196 |
197 | ### Custom Schema Configuration
198 |
199 | You can customize the database field name:
200 |
201 | ```ts title="auth.ts"
202 | import { betterAuth } from "better-auth"
203 | import { lastLoginMethod } from "better-auth/plugins"
204 |
205 | export const auth = betterAuth({
206 | plugins: [
207 | lastLoginMethod({
208 | storeInDatabase: true,
209 | schema: {
210 | user: {
211 | lastLoginMethod: "last_auth_method" // Custom field name
212 | }
213 | }
214 | })
215 | ]
216 | })
217 | ```
218 |
219 | ## Configuration Options
220 |
221 | The last login method plugin accepts the following options:
222 |
223 | ### Server Options
224 |
225 | ```ts title="auth.ts"
226 | import { betterAuth } from "better-auth"
227 | import { lastLoginMethod } from "better-auth/plugins"
228 |
229 | export const auth = betterAuth({
230 | plugins: [
231 | lastLoginMethod({
232 | // Cookie configuration
233 | cookieName: "better-auth.last_used_login_method", // Default: "better-auth.last_used_login_method"
234 | maxAge: 60 * 60 * 24 * 30, // Default: 30 days in seconds
235 |
236 | // Database persistence
237 | storeInDatabase: false, // Default: false
238 |
239 | // Custom method resolution
240 | customResolveMethod: (ctx) => {
241 | // Custom logic to determine the login method
242 | if (ctx.path === "/oauth/callback/custom-provider") {
243 | return "custom-provider"
244 | }
245 | // Return null to use default resolution
246 | return null
247 | },
248 |
249 | // Schema customization (when storeInDatabase is true)
250 | schema: {
251 | user: {
252 | lastLoginMethod: "custom_field_name"
253 | }
254 | }
255 | })
256 | ]
257 | })
258 | ```
259 |
260 | **cookieName**: `string`
261 | - The name of the cookie used to store the last login method
262 | - Default: `"better-auth.last_used_login_method"`
263 | - **Note**: This cookie is `httpOnly: false` to allow client-side JavaScript access for UI features
264 |
265 | **maxAge**: `number`
266 | - Cookie expiration time in seconds
267 | - Default: `2592000` (30 days)
268 |
269 | **storeInDatabase**: `boolean`
270 | - Whether to store the last login method in the database
271 | - Default: `false`
272 | - When enabled, adds a `lastLoginMethod` field to the user table
273 |
274 | **customResolveMethod**: `(ctx: GenericEndpointContext) => string | null`
275 | - Custom function to determine the login method from the request context
276 | - Return `null` to use the default resolution logic
277 | - Useful for custom OAuth providers or authentication flows
278 |
279 | **schema**: `object`
280 | - Customize database field names when `storeInDatabase` is enabled
281 | - Allows mapping the `lastLoginMethod` field to a custom column name
282 |
283 | ### Client Options
284 |
285 | ```ts title="auth-client.ts"
286 | import { createAuthClient } from "better-auth/client"
287 | import { lastLoginMethodClient } from "better-auth/client/plugins"
288 |
289 | export const authClient = createAuthClient({
290 | plugins: [
291 | lastLoginMethodClient({
292 | cookieName: "better-auth.last_used_login_method" // Default: "better-auth.last_used_login_method"
293 | })
294 | ]
295 | })
296 | ```
297 |
298 | **cookieName**: `string`
299 | - The name of the cookie to read the last login method from
300 | - Must match the server-side `cookieName` configuration
301 | - Default: `"better-auth.last_used_login_method"`
302 |
303 | ### Default Method Resolution
304 |
305 | By default, the plugin tracks these authentication methods:
306 |
307 | - **Email authentication**: `"email"`
308 | - **OAuth providers**: Provider ID (e.g., `"google"`, `"github"`, `"discord"`)
309 | - **OAuth2 callbacks**: Provider ID from URL path
310 | - **Sign up methods**: Tracked the same as sign in methods
311 |
312 | The plugin automatically detects the method from these endpoints:
313 | - `/callback/:id` - OAuth callback with provider ID
314 | - `/oauth2/callback/:id` - OAuth2 callback with provider ID
315 | - `/sign-in/email` - Email sign in
316 | - `/sign-up/email` - Email sign up
317 |
318 | ## Cross-Domain Support
319 |
320 | The plugin automatically inherits cookie settings from Better Auth's centralized cookie system. This solves the problem where the last login method wouldn't persist across:
321 |
322 | - **Cross-subdomain setups**: `auth.example.com` → `app.example.com`
323 | - **Cross-origin setups**: `api.company.com` → `app.different.com`
324 |
325 | When you enable `crossSubDomainCookies` or `crossOriginCookies` in your Better Auth config, the plugin will automatically use the same domain, secure, and sameSite settings as your session cookies, ensuring consistent behavior across your application.
326 |
327 | ## Advanced Examples
328 |
329 | ### Custom Provider Tracking
330 |
331 | If you have custom OAuth providers or authentication methods, you can use the `customResolveMethod` option:
332 |
333 | ```ts title="auth.ts"
334 | import { betterAuth } from "better-auth"
335 | import { lastLoginMethod } from "better-auth/plugins"
336 |
337 | export const auth = betterAuth({
338 | plugins: [
339 | lastLoginMethod({
340 | customResolveMethod: (ctx) => {
341 | // Track custom SAML provider
342 | if (ctx.path === "/saml/callback") {
343 | return "saml"
344 | }
345 |
346 | // Track magic link authentication
347 | if (ctx.path === "/magic-link/verify") {
348 | return "magic-link"
349 | }
350 |
351 | // Track phone authentication
352 | if (ctx.path === "/sign-in/phone") {
353 | return "phone"
354 | }
355 |
356 | // Return null to use default logic
357 | return null
358 | }
359 | })
360 | ]
361 | })
362 | ```
363 |
364 |
365 |
```
--------------------------------------------------------------------------------
/docs/components/builder/sign-in.tsx:
--------------------------------------------------------------------------------
```typescript
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Card,
6 | CardContent,
7 | CardDescription,
8 | CardFooter,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card";
12 | import { Checkbox } from "@/components/ui/checkbox";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { Key } from "lucide-react";
16 | import Link from "next/link";
17 | import { useAtom } from "jotai";
18 | import { optionsAtom } from "./store";
19 | import { socialProviders } from "./social-provider";
20 | import { cn } from "@/lib/utils";
21 |
22 | export default function SignIn() {
23 | const [options] = useAtom(optionsAtom);
24 | return (
25 | <Card className="z-50 rounded-none max-w-full">
26 | <CardHeader>
27 | <CardTitle className="text-lg md:text-xl">Sign In</CardTitle>
28 | <CardDescription className="text-xs md:text-sm">
29 | Enter your email below to login to your account
30 | </CardDescription>
31 | </CardHeader>
32 | <CardContent>
33 | <div className="grid gap-4">
34 | {options.email && (
35 | <>
36 | <div className="grid gap-2">
37 | <Label htmlFor="email">Email</Label>
38 | <Input
39 | id="email"
40 | type="email"
41 | placeholder="[email protected]"
42 | required
43 | />
44 | </div>
45 |
46 | <div className="grid gap-2">
47 | <div className="flex items-center">
48 | <Label htmlFor="password">Password</Label>
49 | {options.requestPasswordReset && (
50 | <Link
51 | href="#"
52 | className="ml-auto inline-block text-sm underline"
53 | >
54 | Forgot your password?
55 | </Link>
56 | )}
57 | </div>
58 |
59 | <Input
60 | id="password"
61 | type="password"
62 | placeholder="password"
63 | autoComplete="password"
64 | />
65 | </div>
66 |
67 | {options.rememberMe && (
68 | <div className="flex items-center gap-2">
69 | <Checkbox />
70 | <Label>Remember me</Label>
71 | </div>
72 | )}
73 | </>
74 | )}
75 |
76 | {options.magicLink && (
77 | <div className="grid gap-2">
78 | <Label htmlFor="email">Email</Label>
79 | <Input
80 | id="email"
81 | type="email"
82 | placeholder="[email protected]"
83 | required
84 | />
85 | <Button className="gap-2" onClick={async () => {}}>
86 | Sign-in with Magic Link
87 | </Button>
88 | </div>
89 | )}
90 |
91 | {options.email && (
92 | <Button type="submit" className="w-full" onClick={async () => {}}>
93 | Login
94 | </Button>
95 | )}
96 |
97 | {options.passkey && (
98 | <Button variant="secondary" className="gap-2">
99 | <Key size={16} />
100 | Sign-in with Passkey
101 | </Button>
102 | )}
103 | <div
104 | className={cn(
105 | "w-full gap-2 flex items-center justify-between",
106 | options.socialProviders.length > 3
107 | ? "flex-row flex-wrap"
108 | : "flex-col",
109 | )}
110 | >
111 | {Object.keys(socialProviders).map((provider) => {
112 | if (options.socialProviders.includes(provider)) {
113 | const { Icon } =
114 | socialProviders[provider as keyof typeof socialProviders];
115 | return (
116 | <Button
117 | key={provider}
118 | variant="outline"
119 | className={cn(
120 | options.socialProviders.length > 3
121 | ? "flex-grow"
122 | : "w-full gap-2",
123 | )}
124 | >
125 | <Icon width="1.2em" height="1.2em" />
126 | {options.socialProviders.length <= 3 &&
127 | "Sign in with " +
128 | provider.charAt(0).toUpperCase() +
129 | provider.slice(1)}
130 | </Button>
131 | );
132 | }
133 | return null;
134 | })}
135 | </div>
136 | </div>
137 | </CardContent>
138 | {options.label && (
139 | <CardFooter>
140 | <div className="flex justify-center w-full border-t py-4">
141 | <p className="text-center text-xs text-neutral-500">
142 | built with{" "}
143 | <Link
144 | href="https://better-auth.com"
145 | className="underline"
146 | target="_blank"
147 | >
148 | <span className="dark:text-white/70 cursor-pointer">
149 | better-auth.
150 | </span>
151 | </Link>
152 | </p>
153 | </div>
154 | </CardFooter>
155 | )}
156 | </Card>
157 | );
158 | }
159 |
160 | export const signInString = (options: any) => `"use client"
161 |
162 | import { Button } from "@/components/ui/button";
163 | import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
164 | import { Input } from "@/components/ui/input";
165 | import { Label } from "@/components/ui/label";
166 | import { Checkbox } from "@/components/ui/checkbox";
167 | import { useState } from "react";
168 | import { Loader2, Key } from "lucide-react";
169 | import { signIn } from "@/lib/auth-client";
170 | import Link from "next/link";
171 | import { cn } from "@/lib/utils";
172 |
173 | export default function SignIn() {
174 | const [email, setEmail] = useState("");
175 | const [password, setPassword] = useState("");
176 | const [loading, setLoading] = useState(false);
177 | ${
178 | options.rememberMe
179 | ? "const [rememberMe, setRememberMe] = useState(false);"
180 | : ""
181 | }
182 |
183 | return (
184 | <Card className="max-w-md">
185 | <CardHeader>
186 | <CardTitle className="text-lg md:text-xl">Sign In</CardTitle>
187 | <CardDescription className="text-xs md:text-sm">
188 | Enter your email below to login to your account
189 | </CardDescription>
190 | </CardHeader>
191 | <CardContent>
192 | <div className="grid gap-4">
193 | ${
194 | options.email
195 | ? `<div className="grid gap-2">
196 | <Label htmlFor="email">Email</Label>
197 | <Input
198 | id="email"
199 | type="email"
200 | placeholder="[email protected]"
201 | required
202 | onChange={(e) => {
203 | setEmail(e.target.value);
204 | }}
205 | value={email}
206 | />
207 | </div>
208 |
209 | <div className="grid gap-2">
210 | <div className="flex items-center">
211 | <Label htmlFor="password">Password</Label>
212 | ${
213 | options.requestPasswordReset
214 | ? `<Link
215 | href="#"
216 | className="ml-auto inline-block text-sm underline"
217 | >
218 | Forgot your password?
219 | </Link>`
220 | : ""
221 | }
222 | </div>
223 |
224 | <Input
225 | id="password"
226 | type="password"
227 | placeholder="password"
228 | autoComplete="password"
229 | value={password}
230 | onChange={(e) => setPassword(e.target.value)}
231 | />
232 | </div>
233 |
234 | ${
235 | options.rememberMe
236 | ? `<div className="flex items-center gap-2">
237 | <Checkbox
238 | id="remember"
239 | onClick={() => {
240 | setRememberMe(!rememberMe);
241 | }}
242 | />
243 | <Label htmlFor="remember">Remember me</Label>
244 | </div>`
245 | : ""
246 | }`
247 | : ""
248 | }
249 |
250 | ${
251 | options.magicLink
252 | ? `<div className="grid gap-2">
253 | <Label htmlFor="email">Email</Label>
254 | <Input
255 | id="email"
256 | type="email"
257 | placeholder="[email protected]"
258 | required
259 | onChange={(e) => {
260 | setEmail(e.target.value);
261 | }}
262 | value={email}
263 | />
264 | <Button
265 | disabled={loading}
266 | className="gap-2"
267 | onClick={async () => {
268 | await signIn.magicLink(
269 | {
270 | email
271 | },
272 | {
273 | onRequest: (ctx) => {
274 | setLoading(true);
275 | },
276 | onResponse: (ctx) => {
277 | setLoading(false);
278 | },
279 | },
280 | );
281 | }}>
282 | {loading ? (
283 | <Loader2 size={16} className="animate-spin" />
284 | ):(
285 | Sign-in with Magic Link
286 | )}
287 | </Button>
288 | </div>`
289 | : ""
290 | }
291 |
292 | ${
293 | options.email
294 | ? `<Button
295 | type="submit"
296 | className="w-full"
297 | disabled={loading}
298 | onClick={async () => {
299 | await signIn.email(
300 | {
301 | email,
302 | password
303 | },
304 | {
305 | onRequest: (ctx) => {
306 | setLoading(true);
307 | },
308 | onResponse: (ctx) => {
309 | setLoading(false);
310 | },
311 | },
312 | );
313 | }}
314 | >
315 | {loading ? (
316 | <Loader2 size={16} className="animate-spin" />
317 | ) : (
318 | <p> Login </p>
319 | )}
320 | </Button>`
321 | : ""
322 | }
323 |
324 | ${
325 | options.passkey
326 | ? `<Button
327 | variant="secondary"
328 | disabled={loading}
329 | className="gap-2"
330 | onClick={async () => {
331 | await signIn.passkey(
332 | {
333 | onRequest: (ctx) => {
334 | setLoading(true);
335 | },
336 | onResponse: (ctx) => {
337 | setLoading(false);
338 | },
339 | },
340 | )
341 | }}
342 | >
343 | <Key size={16} />
344 | Sign-in with Passkey
345 | </Button>`
346 | : ""
347 | }
348 |
349 | ${
350 | options.socialProviders?.length > 0
351 | ? `<div className={cn(
352 | "w-full gap-2 flex items-center",
353 | ${
354 | options.socialProviders.length > 3
355 | ? '"justify-between flex-wrap"'
356 | : '"justify-between flex-col"'
357 | }
358 | )}>
359 | ${options.socialProviders
360 | .map((provider: string) => {
361 | const icon =
362 | socialProviders[provider as keyof typeof socialProviders]
363 | ?.stringIcon || "";
364 | return `\n\t\t\t\t<Button
365 | variant="outline"
366 | className={cn(
367 | ${
368 | options.socialProviders.length > 3
369 | ? '"flex-grow"'
370 | : '"w-full gap-2"'
371 | }
372 | )}
373 | disabled={loading}
374 | onClick={async () => {
375 | await signIn.social(
376 | {
377 | provider: "${provider}",
378 | callbackURL: "/dashboard"
379 | },
380 | {
381 | onRequest: (ctx) => {
382 | setLoading(true);
383 | },
384 | onResponse: (ctx) => {
385 | setLoading(false);
386 | },
387 | },
388 | );
389 | }}
390 | >
391 | ${icon}
392 | ${
393 | options.socialProviders.length <= 3
394 | ? `Sign in with ${
395 | provider.charAt(0).toUpperCase() + provider.slice(1)
396 | }`
397 | : ""
398 | }
399 | </Button>`;
400 | })
401 | .join("")}
402 | </div>`
403 | : ""
404 | }
405 | </div>
406 | </CardContent>
407 | ${
408 | options.label
409 | ? `<CardFooter>
410 | <div className="flex justify-center w-full border-t py-4">
411 | <p className="text-center text-xs text-neutral-500">
412 | built with{" "}
413 | <Link
414 | href="https://better-auth.com"
415 | className="underline"
416 | target="_blank"
417 | >
418 | <span className="dark:text-white/70 cursor-pointer">
419 | better-auth.
420 | </span>
421 | </Link>
422 | </p>
423 | </div>
424 | </CardFooter>`
425 | : ""
426 | }
427 | </Card>
428 | );
429 | }`;
430 |
```
--------------------------------------------------------------------------------
/packages/better-auth/src/api/routes/update-user.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, expect, it, vi } from "vitest";
2 | import { getTestInstance } from "../../test-utils/test-instance";
3 | import type { Account } from "../../types";
4 |
5 | describe("updateUser", async () => {
6 | const sendChangeEmail = vi.fn();
7 | let emailVerificationToken = "";
8 | const {
9 | client,
10 | testUser,
11 | sessionSetter,
12 | db,
13 | customFetchImpl,
14 | signInWithTestUser,
15 | } = await getTestInstance({
16 | emailVerification: {
17 | async sendVerificationEmail({ user, url, token }) {
18 | emailVerificationToken = token;
19 | },
20 | },
21 | user: {
22 | changeEmail: {
23 | enabled: true,
24 | sendChangeEmailVerification: async ({ user, newEmail, url, token }) => {
25 | sendChangeEmail(user, newEmail, url, token);
26 | },
27 | },
28 | },
29 | });
30 | // Sign in once for all tests in this describe block
31 | const { runWithUser: globalRunWithClient } = await signInWithTestUser();
32 |
33 | it("should update the user's name", async () => {
34 | await globalRunWithClient(async () => {
35 | const updated = await client.updateUser({
36 | name: "newName",
37 | image: "https://example.com/image.jpg",
38 | });
39 | const sessionRes = await client.getSession();
40 | expect(updated.data?.status).toBe(true);
41 | expect(sessionRes.data?.user.name).toBe("newName");
42 | });
43 | });
44 |
45 | it("should unset image", async () => {
46 | await globalRunWithClient(async () => {
47 | const updated = await client.updateUser({
48 | image: null,
49 | });
50 | const sessionRes = await client.getSession();
51 | expect(sessionRes.data?.user.image).toBeNull();
52 | });
53 | });
54 |
55 | it("should update user email", async () => {
56 | const newEmail = "[email protected]";
57 | await globalRunWithClient(async () => {
58 | const res = await client.changeEmail({
59 | newEmail,
60 | });
61 | const sessionRes = await client.getSession();
62 | expect(sessionRes.data?.user.email).toBe(newEmail);
63 | expect(sessionRes.data?.user.emailVerified).toBe(false);
64 | });
65 | });
66 |
67 | it("should verify email", async () => {
68 | await globalRunWithClient(async () => {
69 | await client.verifyEmail({
70 | query: {
71 | token: emailVerificationToken,
72 | },
73 | });
74 | const sessionRes = await client.getSession();
75 | expect(sessionRes.data?.user.emailVerified).toBe(true);
76 | });
77 | });
78 |
79 | it("should send email verification before update", async () => {
80 | await db.update({
81 | model: "user",
82 | update: {
83 | emailVerified: true,
84 | },
85 | where: [
86 | {
87 | field: "email",
88 | value: "[email protected]",
89 | },
90 | ],
91 | });
92 | await globalRunWithClient(async () => {
93 | await client.changeEmail({
94 | newEmail: "[email protected]",
95 | });
96 | });
97 | expect(sendChangeEmail).toHaveBeenCalledWith(
98 | expect.objectContaining({
99 | email: "[email protected]",
100 | }),
101 | "[email protected]",
102 | expect.any(String),
103 | expect.any(String),
104 | );
105 | });
106 |
107 | it("should update the user's password", async () => {
108 | const newEmail = "[email protected]";
109 | await globalRunWithClient(async () => {
110 | const updated = await client.changePassword({
111 | newPassword: "newPassword",
112 | currentPassword: testUser.password,
113 | revokeOtherSessions: true,
114 | });
115 | expect(updated).toBeDefined();
116 | });
117 | const signInRes = await client.signIn.email({
118 | email: newEmail,
119 | password: "newPassword",
120 | });
121 | expect(signInRes.data?.user).toBeDefined();
122 | const signInCurrentPassword = await client.signIn.email({
123 | email: testUser.email,
124 | password: testUser.password,
125 | });
126 | expect(signInCurrentPassword.data).toBeNull();
127 | });
128 |
129 | it("should update account's updatedAt when changing password", async () => {
130 | const newHeaders = new Headers();
131 | await client.signUp.email({
132 | name: "Test User",
133 | email: "[email protected]",
134 | password: "originalPassword",
135 | fetchOptions: {
136 | onSuccess: sessionSetter(newHeaders),
137 | },
138 | });
139 |
140 | // Get the initial account data
141 | const initialSession = await client.getSession({
142 | fetchOptions: {
143 | headers: newHeaders,
144 | throw: true,
145 | },
146 | });
147 | const userId = initialSession?.user.id;
148 |
149 | // Get initial account updatedAt
150 | const initialAccounts: Account[] = await db.findMany({
151 | model: "account",
152 | where: [
153 | {
154 | field: "userId",
155 | value: userId!,
156 | },
157 | {
158 | field: "providerId",
159 | value: "credential",
160 | },
161 | ],
162 | });
163 | expect(initialAccounts.length).toBe(1);
164 | const initialUpdatedAt = initialAccounts[0]!.updatedAt;
165 |
166 | await new Promise((resolve) => setTimeout(resolve, 100));
167 |
168 | // Change password
169 | const updated = await client.changePassword({
170 | newPassword: "newPassword123",
171 | currentPassword: "originalPassword",
172 | fetchOptions: {
173 | headers: newHeaders,
174 | },
175 | });
176 | expect(updated.data).toBeDefined();
177 |
178 | // Get updated account data
179 | const updatedAccounts: Account[] = await db.findMany({
180 | model: "account",
181 | where: [
182 | {
183 | field: "userId",
184 | value: userId!,
185 | },
186 | {
187 | field: "providerId",
188 | value: "credential",
189 | },
190 | ],
191 | });
192 | expect(updatedAccounts.length).toBe(1);
193 | const newUpdatedAt = updatedAccounts[0]!.updatedAt;
194 |
195 | // Verify updatedAt was refreshed
196 | expect(newUpdatedAt).not.toBe(initialUpdatedAt);
197 | expect(new Date(newUpdatedAt).getTime()).toBeGreaterThan(
198 | new Date(initialUpdatedAt).getTime(),
199 | );
200 | });
201 |
202 | it("should not update password if current password is wrong", async () => {
203 | const newHeaders = new Headers();
204 | await client.signUp.email({
205 | name: "name",
206 | email: "[email protected]",
207 | password: "password",
208 | fetchOptions: {
209 | onSuccess: sessionSetter(newHeaders),
210 | },
211 | });
212 | const res = await client.changePassword({
213 | newPassword: "newPassword",
214 | currentPassword: "wrongPassword",
215 | fetchOptions: {
216 | headers: newHeaders,
217 | },
218 | });
219 | expect(res.data).toBeNull();
220 | const signInAttempt = await client.signIn.email({
221 | email: "[email protected]",
222 | password: "newPassword",
223 | });
224 | expect(signInAttempt.data).toBeNull();
225 | });
226 |
227 | it("should revoke other sessions", async () => {
228 | await globalRunWithClient(async (headers) => {
229 | const newHeaders = new Headers();
230 | await client.changePassword({
231 | newPassword: "newPassword",
232 | currentPassword: testUser.password,
233 | revokeOtherSessions: true,
234 | fetchOptions: {
235 | onSuccess: sessionSetter(newHeaders),
236 | },
237 | });
238 | const cookie = newHeaders.get("cookie");
239 | const oldCookie = headers.get("cookie");
240 | expect(cookie).not.toBe(oldCookie);
241 | // Try to use the old session - it should be revoked
242 | const sessionAttempt = await client.getSession();
243 | // The old session should still be invalidated even though we're using runWithClient
244 | // because revokeOtherSessions should have invalidated it on the server
245 | expect(sessionAttempt.data).toBeNull();
246 | });
247 | });
248 |
249 | it("shouldn't pass defaults", async () => {
250 | const { client, sessionSetter, db } = await getTestInstance(
251 | {
252 | user: {
253 | additionalFields: {
254 | newField: {
255 | type: "string",
256 | defaultValue: "default",
257 | },
258 | },
259 | },
260 | },
261 | {
262 | disableTestUser: true,
263 | },
264 | );
265 | const headers = new Headers();
266 | await client.signUp.email({
267 | email: "[email protected]",
268 | name: "name",
269 | password: "password",
270 | fetchOptions: {
271 | onSuccess: sessionSetter(headers),
272 | },
273 | });
274 |
275 | const res = await db.update<{ newField: string }>({
276 | model: "user",
277 | update: {
278 | newField: "new",
279 | },
280 | where: [
281 | {
282 | field: "email",
283 | value: "[email protected]",
284 | },
285 | ],
286 | });
287 | expect(res?.newField).toBe("new");
288 |
289 | const updated = await client.updateUser({
290 | name: "newName",
291 | fetchOptions: {
292 | headers,
293 | },
294 | });
295 | const session = await client.getSession({
296 | fetchOptions: {
297 | headers,
298 | throw: true,
299 | },
300 | });
301 | // @ts-expect-error
302 | expect(session?.user.newField).toBe("new");
303 | });
304 |
305 | it("should propagate updates across sessions when secondaryStorage is enabled", async () => {
306 | const store = new Map<string, string>();
307 | const { client: authClient, signInWithTestUser: signIn } =
308 | await getTestInstance({
309 | secondaryStorage: {
310 | set(key, value) {
311 | store.set(key, value);
312 | },
313 | get(key) {
314 | return store.get(key) || null;
315 | },
316 | delete(key) {
317 | store.delete(key);
318 | },
319 | },
320 | });
321 |
322 | const { headers: headers1 } = await signIn();
323 | const { headers: headers2 } = await signIn();
324 |
325 | await authClient.updateUser({
326 | name: "updatedName",
327 | fetchOptions: {
328 | headers: headers1,
329 | },
330 | });
331 |
332 | const secondSession = await authClient.getSession({
333 | fetchOptions: {
334 | headers: headers2,
335 | throw: true,
336 | },
337 | });
338 | expect(secondSession?.user.name).toBe("updatedName");
339 |
340 | const firstSession = await authClient.getSession({
341 | fetchOptions: {
342 | headers: headers1,
343 | throw: true,
344 | },
345 | });
346 |
347 | expect(firstSession?.user.name).toBe("updatedName");
348 | });
349 | });
350 |
351 | describe("delete user", async () => {
352 | it("should not delete user if deleteUser is disabled", async () => {
353 | const { client, signInWithTestUser } = await getTestInstance({
354 | user: {
355 | deleteUser: {
356 | enabled: false,
357 | },
358 | },
359 | });
360 | const { runWithUser } = await signInWithTestUser();
361 | await runWithUser(async () => {
362 | const res = await client.deleteUser();
363 | console.log(res);
364 | });
365 | });
366 | it("should delete the user with a fresh session", async () => {
367 | const { client, signInWithTestUser } = await getTestInstance({
368 | user: {
369 | deleteUser: {
370 | enabled: true,
371 | },
372 | },
373 | session: {
374 | freshAge: 1000,
375 | },
376 | });
377 | const { runWithUser } = await signInWithTestUser();
378 | await runWithUser(async () => {
379 | const res = await client.deleteUser();
380 | expect(res.data).toMatchObject({
381 | success: true,
382 | });
383 | const session = await client.getSession();
384 | expect(session.data).toBeNull();
385 | });
386 | });
387 |
388 | it("should delete with verification flow and password", async () => {
389 | let token = "";
390 | const { client, signInWithTestUser, testUser } = await getTestInstance({
391 | user: {
392 | deleteUser: {
393 | enabled: true,
394 | async sendDeleteAccountVerification(data, _) {
395 | token = data.token;
396 | },
397 | },
398 | },
399 | });
400 | const { runWithUser } = await signInWithTestUser();
401 | await runWithUser(async () => {
402 | const res = await client.deleteUser({
403 | password: testUser.password,
404 | });
405 | expect(res.data).toMatchObject({
406 | success: true,
407 | });
408 | expect(token.length).toBe(32);
409 | const session = await client.getSession();
410 | expect(session.data).toBeDefined();
411 | const deleteCallbackRes = await client.deleteUser({
412 | token,
413 | });
414 | expect(deleteCallbackRes.data).toMatchObject({
415 | success: true,
416 | });
417 | const nullSession = await client.getSession();
418 | expect(nullSession.data).toBeNull();
419 | });
420 | });
421 |
422 | it("should ignore cookie cache for sensitive operations like changePassword", async () => {
423 | const { client: cacheClient, sessionSetter: cacheSessionSetter } =
424 | await getTestInstance(
425 | {
426 | session: {
427 | cookieCache: {
428 | enabled: true,
429 | maxAge: 60,
430 | },
431 | },
432 | },
433 | {
434 | disableTestUser: true,
435 | },
436 | );
437 |
438 | const uniqueEmail = `cache-test-${Date.now()}@test.com`;
439 | const testPassword = "testPassword123";
440 |
441 | await cacheClient.signUp.email({
442 | email: uniqueEmail,
443 | password: testPassword,
444 | name: "Cache Test User",
445 | });
446 |
447 | const cacheHeaders = new Headers();
448 | await cacheClient.signIn.email({
449 | email: uniqueEmail,
450 | password: testPassword,
451 | fetchOptions: {
452 | onSuccess: cacheSessionSetter(cacheHeaders),
453 | },
454 | });
455 |
456 | const initialSession = await cacheClient.getSession({
457 | fetchOptions: {
458 | headers: cacheHeaders,
459 | throw: true,
460 | },
461 | });
462 | expect(initialSession?.user).toBeDefined();
463 |
464 | const changePasswordResult = await cacheClient.changePassword({
465 | newPassword: "newSecurePassword123",
466 | currentPassword: testPassword,
467 | revokeOtherSessions: true,
468 | fetchOptions: {
469 | headers: cacheHeaders,
470 | },
471 | });
472 |
473 | expect(changePasswordResult.data).toBeDefined();
474 |
475 | const sessionAfterPasswordChange = await cacheClient.getSession({
476 | fetchOptions: {
477 | headers: cacheHeaders,
478 | },
479 | });
480 |
481 | expect(sessionAfterPasswordChange.data).toBeNull();
482 | });
483 | });
484 |
```
--------------------------------------------------------------------------------
/docs/content/docs/authentication/email-password.mdx:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Email & Password
3 | description: Implementing email and password authentication with Better Auth.
4 | ---
5 |
6 | Email and password authentication is a common method used by many applications. Better Auth provides a built-in email and password authenticator that you can easily integrate into your project.
7 |
8 | <Callout type="info">
9 | If you prefer username-based authentication, check out the{" "}
10 | <Link href="/docs/plugins/username">username plugin</Link>. It extends the
11 | email and password authenticator with username support.
12 | </Callout>
13 |
14 | ## Enable Email and Password
15 |
16 | To enable email and password authentication, you need to set the `emailAndPassword.enabled` option to `true` in the `auth` configuration.
17 |
18 | ```ts title="auth.ts"
19 | import { betterAuth } from "better-auth";
20 |
21 | export const auth = betterAuth({
22 | emailAndPassword: { // [!code highlight]
23 | enabled: true, // [!code highlight]
24 | }, // [!code highlight]
25 | });
26 | ```
27 |
28 | <Callout type="info">
29 | If it's not enabled, it'll not allow you to sign in or sign up with email and
30 | password.
31 | </Callout>
32 |
33 | ## Usage
34 |
35 | ### Sign Up
36 |
37 | To sign a user up, you can use the `signUp.email` function provided by the client.
38 |
39 | <APIMethod path="/sign-up/email" method="POST">
40 | ```ts
41 | type signUpEmail = {
42 | /**
43 | * The name of the user.
44 | */
45 | name: string = "John Doe"
46 | /**
47 | * The email address of the user.
48 | */
49 | email: string = "[email protected]"
50 | /**
51 | * The password of the user. It should be at least 8 characters long and max 128 by default.
52 | */
53 | password: string = "password1234"
54 | /**
55 | * An optional profile image of the user.
56 | */
57 | image?: string = "https://example.com/image.png"
58 | /**
59 | * An optional URL to redirect to after the user signs up.
60 | */
61 | callbackURL?: string = "https://example.com/callback"
62 | }
63 | ```
64 | </APIMethod>
65 |
66 | <Callout>
67 | These are the default properties for the sign up email endpoint, however it's possible that with [additional fields](/docs/concepts/typescript#additional-fields) or special plugins you can pass more properties to the endpoint.
68 | </Callout>
69 |
70 |
71 | ### Sign In
72 |
73 | To sign a user in, you can use the `signIn.email` function provided by the client.
74 |
75 | <APIMethod path="/sign-in/email" method="POST" requireSession>
76 | ```ts
77 | type signInEmail = {
78 | /**
79 | * The email address of the user.
80 | */
81 | email: string = "[email protected]"
82 | /**
83 | * The password of the user. It should be at least 8 characters long and max 128 by default.
84 | */
85 | password: string = "password1234"
86 | /**
87 | * If false, the user will be signed out when the browser is closed. (optional) (default: true)
88 | */
89 | rememberMe?: boolean = true
90 | /**
91 | * An optional URL to redirect to after the user signs in. (optional)
92 | */
93 | callbackURL?: string = "https://example.com/callback"
94 | }
95 | ```
96 | </APIMethod>
97 |
98 | <Callout>
99 | These are the default properties for the sign in email endpoint, however it's possible that with [additional fields](/docs/concepts/typescript#additional-fields) or special plugins you can pass different properties to the endpoint.
100 | </Callout>
101 |
102 |
103 | ### Sign Out
104 |
105 | To sign a user out, you can use the `signOut` function provided by the client.
106 |
107 | <APIMethod path="/sign-out" method="POST" requireSession noResult>
108 | ```ts
109 | type signOut = {
110 | }
111 | ```
112 | </APIMethod>
113 |
114 | you can pass `fetchOptions` to redirect onSuccess
115 |
116 | ```ts title="auth-client.ts"
117 | await authClient.signOut({
118 | fetchOptions: {
119 | onSuccess: () => {
120 | router.push("/login"); // redirect to login page
121 | },
122 | },
123 | });
124 | ```
125 |
126 | ### Email Verification
127 |
128 | To enable email verification, you need to pass a function that sends a verification email with a link. The `sendVerificationEmail` function takes a data object with the following properties:
129 |
130 | - `user`: The user object.
131 | - `url`: The URL to send to the user which contains the token.
132 | - `token`: A verification token used to complete the email verification.
133 |
134 | and a `request` object as the second parameter.
135 |
136 | ```ts title="auth.ts"
137 | import { betterAuth } from "better-auth";
138 | import { sendEmail } from "./email"; // your email sending function
139 |
140 | export const auth = betterAuth({
141 | emailVerification: {
142 | sendVerificationEmail: async ( { user, url, token }, request) => {
143 | await sendEmail({
144 | to: user.email,
145 | subject: "Verify your email address",
146 | text: `Click the link to verify your email: ${url}`,
147 | });
148 | },
149 | },
150 | });
151 | ```
152 |
153 | On the client side you can use `sendVerificationEmail` function to send verification link to user. This will trigger the `sendVerificationEmail` function you provided in the `auth` configuration.
154 |
155 | Once the user clicks on the link in the email, if the token is valid, the user will be redirected to the URL provided in the `callbackURL` parameter. If the token is invalid, the user will be redirected to the URL provided in the `callbackURL` parameter with an error message in the query string `?error=invalid_token`.
156 |
157 | #### Require Email Verification
158 |
159 | If you enable require email verification, users must verify their email before they can log in. And every time a user tries to sign in, sendVerificationEmail is called.
160 |
161 | <Callout>
162 | This only works if you have sendVerificationEmail implemented and if the user
163 | is trying to sign in with email and password.
164 | </Callout>
165 |
166 | ```ts title="auth.ts"
167 | export const auth = betterAuth({
168 | emailAndPassword: {
169 | requireEmailVerification: true,
170 | },
171 | });
172 | ```
173 |
174 | If a user tries to sign in without verifying their email, you can handle the error and show a message to the user.
175 |
176 | ```ts title="auth-client.ts"
177 | await authClient.signIn.email(
178 | {
179 | email: "[email protected]",
180 | password: "password",
181 | },
182 | {
183 | onError: (ctx) => {
184 | // Handle the error
185 | if (ctx.error.status === 403) {
186 | alert("Please verify your email address");
187 | }
188 | //you can also show the original error message
189 | alert(ctx.error.message);
190 | },
191 | }
192 | );
193 | ```
194 |
195 | #### Triggering manually Email Verification
196 |
197 | You can trigger the email verification manually by calling the `sendVerificationEmail` function.
198 |
199 | ```ts
200 | await authClient.sendVerificationEmail({
201 | email: "[email protected]",
202 | callbackURL: "/", // The redirect URL after verification
203 | });
204 | ```
205 |
206 | ### Request Password Reset
207 |
208 | To allow users to reset a password first you need to provide `sendResetPassword` function to the email and password authenticator. The `sendResetPassword` function takes a data object with the following properties:
209 |
210 | - `user`: The user object.
211 | - `url`: The URL to send to the user which contains the token.
212 | - `token`: A verification token used to complete the password reset.
213 |
214 | and a `request` object as the second parameter.
215 |
216 | ```ts title="auth.ts"
217 | import { betterAuth } from "better-auth";
218 | import { sendEmail } from "./email"; // your email sending function
219 |
220 | export const auth = betterAuth({
221 | emailAndPassword: {
222 | enabled: true,
223 | sendResetPassword: async ({user, url, token}, request) => {
224 | await sendEmail({
225 | to: user.email,
226 | subject: "Reset your password",
227 | text: `Click the link to reset your password: ${url}`,
228 | });
229 | },
230 | onPasswordReset: async ({ user }, request) => {
231 | // your logic here
232 | console.log(`Password for user ${user.email} has been reset.`);
233 | },
234 | },
235 | });
236 | ```
237 |
238 | Additionally, you can provide an `onPasswordReset` callback to execute logic after a password has been successfully reset.
239 |
240 | Once you configured your server you can call `requestPasswordReset` function to send reset password link to user. If the user exists, it will trigger the `sendResetPassword` function you provided in the auth config.
241 |
242 | <APIMethod path="/request-password-reset" method="POST">
243 | ```ts
244 | type requestPasswordReset = {
245 | /**
246 | * The email address of the user to send a password reset email to
247 | */
248 | email: string = "[email protected]"
249 | /**
250 | * The URL to redirect the user to reset their password. If the token isn't valid or expired, it'll be redirected with a query parameter `?error=INVALID_TOKEN`. If the token is valid, it'll be redirected with a query parameter `?token=VALID_TOKEN
251 | */
252 | redirectTo?: string = "https://example.com/reset-password"
253 | }
254 | ```
255 | </APIMethod>
256 |
257 | When a user clicks on the link in the email, they will be redirected to the reset password page. You can add the reset password page to your app. Then you can use `resetPassword` function to reset the password. It takes an object with the following properties:
258 |
259 | - `newPassword`: The new password of the user.
260 |
261 | ```ts title="auth-client.ts"
262 | const { data, error } = await authClient.resetPassword({
263 | newPassword: "password1234",
264 | token,
265 | });
266 | ```
267 |
268 | <APIMethod path="/reset-password" method="POST">
269 | ```ts
270 | const token = new URLSearchParams(window.location.search).get("token");
271 |
272 | if (!token) {
273 | // Handle the error
274 | }
275 |
276 | type resetPassword = {
277 | /**
278 | * The new password to set
279 | */
280 | newPassword: string = "password1234"
281 | /**
282 | * The token to reset the password
283 | */
284 | token: string
285 | }
286 | ```
287 | </APIMethod>
288 |
289 | ### Update password
290 | A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches:
291 |
292 |
293 | <APIMethod path="/change-password" method="POST" requireSession>
294 | ```ts
295 | type changePassword = {
296 | /**
297 | * The new password to set
298 | */
299 | newPassword: string = "newpassword1234"
300 | /**
301 | * The current user password
302 | */
303 | currentPassword: string = "oldpassword1234"
304 | /**
305 | * When set to true, all other active sessions for this user will be invalidated
306 | */
307 | revokeOtherSessions?: boolean = true
308 | }
309 | ```
310 | </APIMethod>
311 |
312 | ### Configuration
313 |
314 | **Password**
315 |
316 | Better Auth stores passwords inside the `account` table with `providerId` set to `credential`.
317 |
318 | **Password Hashing**: Better Auth uses `scrypt` to hash passwords. The `scrypt` algorithm is designed to be slow and memory-intensive to make it difficult for attackers to brute force passwords. OWASP recommends using `scrypt` if `argon2id` is not available. We decided to use `scrypt` because it's natively supported by Node.js.
319 |
320 | You can pass custom password hashing algorithm by setting `passwordHasher` option in the `auth` configuration.
321 |
322 | ```ts title="auth.ts"
323 | import { betterAuth } from "better-auth"
324 | import { scrypt } from "scrypt"
325 |
326 | export const auth = betterAuth({
327 | //...rest of the options
328 | emailAndPassword: {
329 | password: {
330 | hash: // your custom password hashing function
331 | verify: // your custom password verification function
332 | }
333 | }
334 | })
335 | ```
336 |
337 | <TypeTable
338 | type={{
339 | enabled: {
340 | description: "Enable email and password authentication.",
341 | type: "boolean",
342 | default: "false",
343 | },
344 | disableSignUp: {
345 | description: "Disable email and password sign up.",
346 | type: "boolean",
347 | default: "false"
348 | },
349 | minPasswordLength: {
350 | description: "The minimum length of a password.",
351 | type: "number",
352 | default: 8,
353 | },
354 | maxPasswordLength: {
355 | description: "The maximum length of a password.",
356 | type: "number",
357 | default: 128,
358 | },
359 | sendResetPassword: {
360 | description:
361 | "Sends a password reset email. It takes a function that takes two parameters: token and user.",
362 | type: "function",
363 | },
364 | onPasswordReset: {
365 | description:
366 | "A callback function that is triggered when a user's password is changed successfully.",
367 | type: "function",
368 | },
369 | resetPasswordTokenExpiresIn: {
370 | description:
371 | "Number of seconds the reset password token is valid for.",
372 | type: "number",
373 | default: 3600
374 | },
375 | password: {
376 | description: "Password configuration.",
377 | type: "object",
378 | properties: {
379 | hash: {
380 | description: "custom password hashing function",
381 | type: "function",
382 | },
383 | verify: {
384 | description: "custom password verification function",
385 | type: "function",
386 | },
387 | },
388 | },
389 | }}
390 | />
391 |
```