This is page 35 of 52. Use http://codebase.md/better-auth/better-auth?lines=false&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/device-authorization/index.ts:
--------------------------------------------------------------------------------
```typescript
import type { BetterAuthPlugin } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import { defineErrorCodes } from "@better-auth/core/utils";
import { APIError } from "better-call";
import { type StringValue as MSStringValue, ms } from "ms";
import * as z from "zod";
import { getSessionFromCtx } from "../../api/routes/session";
import { generateRandomString } from "../../crypto";
import { mergeSchema } from "../../db";
import type { InferOptionSchema } from "../../types/plugins";
import { type DeviceCode, schema } from "./schema";
const msStringValueSchema = z.custom<MSStringValue>(
(val) => {
try {
ms(val as MSStringValue);
} catch (e) {
return false;
}
return true;
},
{
message:
"Invalid time string format. Use formats like '30m', '5s', '1h', etc.",
},
);
export const $deviceAuthorizationOptionsSchema = z.object({
expiresIn: msStringValueSchema
.default("30m")
.describe(
"Time in seconds until the device code expires. Use formats like '30m', '5s', '1h', etc.",
),
interval: msStringValueSchema
.default("5s")
.describe(
"Time in seconds between polling attempts. Use formats like '30m', '5s', '1h', etc.",
),
deviceCodeLength: z
.number()
.int()
.positive()
.default(40)
.describe(
"Length of the device code to be generated. Default is 40 characters.",
),
userCodeLength: z
.number()
.int()
.positive()
.default(8)
.describe(
"Length of the user code to be generated. Default is 8 characters.",
),
generateDeviceCode: z
.custom<() => string | Promise<string>>(
(val) => typeof val === "function",
{
message:
"generateDeviceCode must be a function that returns a string or a promise that resolves to a string.",
},
)
.optional()
.describe(
"Function to generate a device code. If not provided, a default random string generator will be used.",
),
generateUserCode: z
.custom<() => string | Promise<string>>(
(val) => typeof val === "function",
{
message:
"generateUserCode must be a function that returns a string or a promise that resolves to a string.",
},
)
.optional()
.describe(
"Function to generate a user code. If not provided, a default random string generator will be used.",
),
validateClient: z
.custom<(clientId: string) => boolean | Promise<boolean>>(
(val) => typeof val === "function",
{
message:
"validateClient must be a function that returns a boolean or a promise that resolves to a boolean.",
},
)
.optional()
.describe(
"Function to validate the client ID. If not provided, no validation will be performed.",
),
onDeviceAuthRequest: z
.custom<
(clientId: string, scope: string | undefined) => void | Promise<void>
>((val) => typeof val === "function", {
message:
"onDeviceAuthRequest must be a function that returns void or a promise that resolves to void.",
})
.optional()
.describe(
"Function to handle device authorization requests. If not provided, no additional actions will be taken.",
),
schema: z.custom<InferOptionSchema<typeof schema>>(() => true),
});
/**
* @see {$deviceAuthorizationOptionsSchema}
*/
export type DeviceAuthorizationOptions = {
expiresIn: MSStringValue;
interval: MSStringValue;
deviceCodeLength: number;
userCodeLength: number;
generateDeviceCode?: () => string | Promise<string>;
generateUserCode?: () => string | Promise<string>;
validateClient?: (clientId: string) => boolean | Promise<boolean>;
onDeviceAuthRequest?: (
clientId: string,
scope: string | undefined,
) => void | Promise<void>;
schema?: InferOptionSchema<typeof schema>;
};
export { deviceAuthorizationClient } from "./client";
const DEVICE_AUTHORIZATION_ERROR_CODES = defineErrorCodes({
INVALID_DEVICE_CODE: "Invalid device code",
EXPIRED_DEVICE_CODE: "Device code has expired",
EXPIRED_USER_CODE: "User code has expired",
AUTHORIZATION_PENDING: "Authorization pending",
ACCESS_DENIED: "Access denied",
INVALID_USER_CODE: "Invalid user code",
DEVICE_CODE_ALREADY_PROCESSED: "Device code already processed",
POLLING_TOO_FREQUENTLY: "Polling too frequently",
USER_NOT_FOUND: "User not found",
FAILED_TO_CREATE_SESSION: "Failed to create session",
INVALID_DEVICE_CODE_STATUS: "Invalid device code status",
AUTHENTICATION_REQUIRED: "Authentication required",
});
const defaultCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/**
* @internal
*/
const defaultGenerateDeviceCode = (length: number) => {
return generateRandomString(length, "a-z", "A-Z", "0-9");
};
/**
* @internal
*/
const defaultGenerateUserCode = (length: number) => {
const chars = new Uint8Array(length);
return Array.from(crypto.getRandomValues(chars))
.map((byte) => defaultCharset[byte % defaultCharset.length])
.join("");
};
export const deviceAuthorization = (
options: Partial<DeviceAuthorizationOptions> = {},
) => {
const opts = $deviceAuthorizationOptionsSchema.parse(options);
const generateDeviceCode = async () => {
if (opts.generateDeviceCode) {
return opts.generateDeviceCode();
}
return defaultGenerateDeviceCode(opts.deviceCodeLength);
};
const generateUserCode = async () => {
if (opts.generateUserCode) {
return opts.generateUserCode();
}
return defaultGenerateUserCode(opts.userCodeLength);
};
return {
id: "device-authorization",
schema: mergeSchema(schema, options?.schema),
endpoints: {
deviceCode: createAuthEndpoint(
"/device/code",
{
method: "POST",
body: z.object({
client_id: z.string().meta({
description: "The client ID of the application",
}),
scope: z
.string()
.meta({
description: "Space-separated list of scopes",
})
.optional(),
}),
error: z.object({
error: z.enum(["invalid_request", "invalid_client"]).meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: `Request a device and user code
Follow [rfc8628#section-3.2](https://datatracker.ietf.org/doc/html/rfc8628#section-3.2)`,
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
device_code: {
type: "string",
description: "The device verification code",
},
user_code: {
type: "string",
description: "The user code to display",
},
verification_uri: {
type: "string",
description: "The URL for user verification",
},
verification_uri_complete: {
type: "string",
description: "The complete URL with user code",
},
expires_in: {
type: "number",
description:
"Lifetime in seconds of the device code",
},
interval: {
type: "number",
description: "Minimum polling interval in seconds",
},
},
},
},
},
},
400: {
description: "Error response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
enum: ["invalid_request", "invalid_client"],
},
error_description: {
type: "string",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
if (opts.validateClient) {
const isValid = await opts.validateClient(ctx.body.client_id);
if (!isValid) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client",
error_description: "Invalid client ID",
});
}
}
if (opts.onDeviceAuthRequest) {
await opts.onDeviceAuthRequest(ctx.body.client_id, ctx.body.scope);
}
const deviceCode = await generateDeviceCode();
const userCode = await generateUserCode();
const expiresIn = ms(opts.expiresIn);
const expiresAt = new Date(Date.now() + expiresIn);
await ctx.context.adapter.create({
model: "deviceCode",
data: {
deviceCode,
userCode,
expiresAt,
status: "pending",
pollingInterval: ms(opts.interval),
clientId: ctx.body.client_id,
scope: ctx.body.scope,
},
});
const baseURL = new URL(ctx.context.baseURL);
const verification_uri = new URL("/device", baseURL);
const verification_uri_complete = new URL(verification_uri);
verification_uri_complete.searchParams.set(
"user_code",
// should we support custom formatting function here?
encodeURIComponent(userCode),
);
return ctx.json(
{
device_code: deviceCode,
user_code: userCode,
verification_uri: verification_uri.toString(),
verification_uri_complete: verification_uri_complete.toString(),
expires_in: Math.floor(expiresIn / 1000),
interval: Math.floor(ms(opts.interval) / 1000),
},
{
headers: {
"Cache-Control": "no-store",
},
},
);
},
),
deviceToken: createAuthEndpoint(
"/device/token",
{
method: "POST",
body: z.object({
grant_type: z
.literal("urn:ietf:params:oauth:grant-type:device_code")
.meta({
description: "The grant type for device flow",
}),
device_code: z.string().meta({
description: "The device verification code",
}),
client_id: z.string().meta({
description: "The client ID of the application",
}),
}),
error: z.object({
error: z
.enum([
"authorization_pending",
"slow_down",
"expired_token",
"access_denied",
"invalid_request",
"invalid_grant",
])
.meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: `Exchange device code for access token
Follow [rfc8628#section-3.4](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4)`,
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
session: {
$ref: "#/components/schemas/Session",
},
user: {
$ref: "#/components/schemas/User",
},
},
},
},
},
},
400: {
description: "Error response",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "string",
enum: [
"authorization_pending",
"slow_down",
"expired_token",
"access_denied",
"invalid_request",
"invalid_grant",
],
},
error_description: {
type: "string",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const { device_code, client_id } = ctx.body;
if (opts.validateClient) {
const isValid = await opts.validateClient(client_id);
if (!isValid) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description: "Invalid client ID",
});
}
}
const deviceCodeRecord = await ctx.context.adapter.findOne<{
id: string;
deviceCode: string;
userCode: string;
userId?: string;
expiresAt: Date;
status: string;
lastPolledAt?: Date;
pollingInterval?: number;
clientId?: string;
scope?: string;
}>({
model: "deviceCode",
where: [
{
field: "deviceCode",
value: device_code,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE,
});
}
if (
deviceCodeRecord.clientId &&
deviceCodeRecord.clientId !== client_id
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_grant",
error_description: "Client ID mismatch",
});
}
// Check for rate limiting
if (
deviceCodeRecord.lastPolledAt &&
deviceCodeRecord.pollingInterval
) {
const timeSinceLastPoll =
Date.now() - new Date(deviceCodeRecord.lastPolledAt).getTime();
const minInterval = deviceCodeRecord.pollingInterval;
if (timeSinceLastPoll < minInterval) {
throw new APIError("BAD_REQUEST", {
error: "slow_down",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.POLLING_TOO_FREQUENTLY,
});
}
}
// Update last polled time
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
lastPolledAt: new Date(),
},
});
if (deviceCodeRecord.expiresAt < new Date()) {
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_DEVICE_CODE,
});
}
if (deviceCodeRecord.status === "pending") {
throw new APIError("BAD_REQUEST", {
error: "authorization_pending",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.AUTHORIZATION_PENDING,
});
}
if (deviceCodeRecord.status === "denied") {
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
throw new APIError("BAD_REQUEST", {
error: "access_denied",
error_description: DEVICE_AUTHORIZATION_ERROR_CODES.ACCESS_DENIED,
});
}
if (
deviceCodeRecord.status === "approved" &&
deviceCodeRecord.userId
) {
// Delete the device code after successful authorization
await ctx.context.adapter.delete({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
});
const user = await ctx.context.internalAdapter.findUserById(
deviceCodeRecord.userId,
);
if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.USER_NOT_FOUND,
});
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
// Set new session context for hooks and plugins
// (matches setSessionCookie logic)
ctx.context.setNewSession({
session,
user,
});
// If secondary storage is enabled, store the session data in the secondary storage
// (matches setSessionCookie logic)
if (ctx.context.options.secondaryStorage) {
await ctx.context.secondaryStorage?.set(
session.token,
JSON.stringify({
user,
session,
}),
Math.floor(
(new Date(session.expiresAt).getTime() - Date.now()) / 1000,
),
);
}
// Return OAuth 2.0 compliant token response
return ctx.json(
{
access_token: session.token,
token_type: "Bearer",
expires_in: Math.floor(
(new Date(session.expiresAt).getTime() - Date.now()) / 1000,
),
scope: deviceCodeRecord.scope || "",
},
{
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
},
);
}
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "server_error",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_DEVICE_CODE_STATUS,
});
},
),
deviceVerify: createAuthEndpoint(
"/device",
{
method: "GET",
query: z.object({
user_code: z.string().meta({
description: "The user code to verify",
}),
}),
error: z.object({
error: z.enum(["invalid_request"]).meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: "Display device verification page",
responses: {
200: {
description: "Verification page HTML",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user_code: {
type: "string",
description: "The user code to verify",
},
status: {
type: "string",
enum: ["pending", "approved", "denied"],
description:
"Current status of the device authorization",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
// This endpoint would typically serve an HTML page for user verification
// For now, we'll return a simple JSON response
const { user_code } = ctx.query;
const cleanUserCode = user_code.replace(/-/g, "");
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: cleanUserCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
return ctx.json({
user_code: user_code,
status: deviceCodeRecord.status,
});
},
),
deviceApprove: createAuthEndpoint(
"/device/approve",
{
method: "POST",
body: z.object({
userCode: z.string().meta({
description: "The user code to approve",
}),
}),
error: z.object({
error: z
.enum([
"invalid_request",
"expired_token",
"device_code_already_processed",
])
.meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
requireHeaders: true,
metadata: {
openapi: {
description: "Approve device authorization",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
if (!session) {
throw new APIError("UNAUTHORIZED", {
error: "unauthorized",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.AUTHENTICATION_REQUIRED,
});
}
const { userCode } = ctx.body;
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: userCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
if (deviceCodeRecord.status !== "pending") {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED,
});
}
// Update device code with approved status and user ID
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
status: "approved",
userId: session.user.id,
},
});
return ctx.json({
success: true,
});
},
),
deviceDeny: createAuthEndpoint(
"/device/deny",
{
method: "POST",
body: z.object({
userCode: z.string().meta({
description: "The user code to deny",
}),
}),
error: z.object({
error: z.enum(["invalid_request", "expired_token"]).meta({
description: "Error code",
}),
error_description: z.string().meta({
description: "Detailed error description",
}),
}),
metadata: {
openapi: {
description: "Deny device authorization",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: {
type: "boolean",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
const { userCode } = ctx.body;
const cleanUserCode = userCode.replace(/-/g, "");
const deviceCodeRecord =
await ctx.context.adapter.findOne<DeviceCode>({
model: "deviceCode",
where: [
{
field: "userCode",
value: cleanUserCode,
},
],
});
if (!deviceCodeRecord) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.INVALID_USER_CODE,
});
}
if (deviceCodeRecord.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
error: "expired_token",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.EXPIRED_USER_CODE,
});
}
if (deviceCodeRecord.status !== "pending") {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description:
DEVICE_AUTHORIZATION_ERROR_CODES.DEVICE_CODE_ALREADY_PROCESSED,
});
}
// Update device code with denied status
await ctx.context.adapter.update({
model: "deviceCode",
where: [
{
field: "id",
value: deviceCodeRecord.id,
},
],
update: {
status: "denied",
},
});
return ctx.json({
success: true,
});
},
),
},
$ERROR_CODES: DEVICE_AUTHORIZATION_ERROR_CODES,
} satisfies BetterAuthPlugin;
};
```
--------------------------------------------------------------------------------
/docs/content/docs/guides/auth0-migration-guide.mdx:
--------------------------------------------------------------------------------
```markdown
---
title: Migrating from Auth0 to Better Auth
description: A step-by-step guide to transitioning from Auth0 to Better Auth.
---
In this guide, we'll walk through the steps to migrate a project from Auth0 to Better Auth — including email/password with proper hashing, social/external accounts, two-factor authentication, and more.
<Callout type="warn">
This migration will invalidate all active sessions. This guide doesn't currently show you how to migrate Organizations but it should be possible with additional steps and the [Organization](/docs/plugins/organization) Plugin.
</Callout>
## Before You Begin
Before starting the migration process, set up Better Auth in your project. Follow the [installation guide](/docs/installation) to get started.
<Steps>
<Step>
### Connect to your database
You'll need to connect to your database to migrate the users and accounts. You can use any database you want, but for this example, we'll use PostgreSQL.
```package-install
npm install pg
```
And then you can use the following code to connect to your database.
```ts title="auth.ts"
import { Pool } from "pg";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
})
```
</Step>
<Step>
### Enable Email and Password (Optional)
Enable the email and password in your auth config and implement your own logic for sending verification emails, reset password emails, etc.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: { // [!code highlight]
enabled: true, // [!code highlight]
}, // [!code highlight]
emailVerification: {
sendVerificationEmail: async({ user, url })=>{
// implement your logic here to send email verification
}
},
})
```
See [Email and Password](/docs/authentication/email-password) for more configuration options.
</Step>
<Step>
### Setup Social Providers (Optional)
Add social providers you have enabled in your Auth0 project in your auth config.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
},
socialProviders: { // [!code highlight]
google: { // [!code highlight]
clientId: process.env.GOOGLE_CLIENT_ID, // [!code highlight]
clientSecret: process.env.GOOGLE_CLIENT_SECRET, // [!code highlight]
}, // [!code highlight]
github: { // [!code highlight]
clientId: process.env.GITHUB_CLIENT_ID, // [!code highlight]
clientSecret: process.env.GITHUB_CLIENT_SECRET, // [!code highlight]
} // [!code highlight]
} // [!code highlight]
})
```
</Step>
<Step>
### Add Plugins (Optional)
You can add the following plugins to your auth config based on your needs.
[Admin](/docs/plugins/admin) Plugin will allow you to manage users, user impersonations and app level roles and permissions.
[Two Factor](/docs/plugins/2fa) Plugin will allow you to add two-factor authentication to your application.
[Username](/docs/plugins/username) Plugin will allow you to add username authentication to your application.
```ts title="auth.ts"
import { Pool } from "pg";
import { betterAuth } from "better-auth";
import { admin, twoFactor, username } from "better-auth/plugins";
export const auth = betterAuth({
database: new Pool({
connectionString: process.env.DATABASE_URL
}),
emailAndPassword: {
enabled: true,
password: {
verify: (data) => {
// this for an edgecase that you might run in to on verifying the password
}
}
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
},
plugins: [admin(), twoFactor(), username()], // [!code highlight]
})
```
</Step>
<Step>
### Generate Schema
If you're using a custom database adapter, generate the schema:
```sh
npx @better-auth/cli generate
```
or if you're using the default adapter, you can use the following command:
```sh
npx @better-auth/cli migrate
```
</Step>
<Step>
### Install Dependencies
Install the required dependencies for the migration:
```bash
npm install auth0
```
</Step>
<Step>
### Create the migration script
Create a new file called `migrate-auth0.ts` in the `scripts` folder and add the following code:
<Callout type="info">
Instead of using the Management API, you can use Auth0's bulk user export functionality and pass the exported JSON data directly to the `auth0Users` array. This is especially useful if you need to migrate password hashes and complete user data, which are not available through the Management API.
**Important Notes:**
- Password hashes export is only available for Auth0 Enterprise users
- Free plan users cannot export password hashes and will need to request a support ticket
- For detailed information about bulk user exports, see the [Auth0 Bulk User Export Documentation](https://auth0.com/docs/manage-users/user-migration/bulk-user-exports)
- For password hash export details, refer to [Exporting Password Hashes](https://auth0.com/docs/troubleshoot/customer-support/manage-subscriptions/export-data#user-passwords)
Example:
```ts
// Replace this with your exported users JSON data
const auth0Users = [
{
"email": "[email protected]",
"email_verified": false,
"name": "Hello world",
// Note: password_hash is only available for Enterprise users
"password_hash": "$2b$10$w4kfaZVjrcQ6ZOMiG.M8JeNvnVQkPKZV03pbDUHbxy9Ug0h/McDXi",
// ... other user data
}
];
```
</Callout>
```ts title="scripts/migrate-auth0.ts"
import { ManagementClient } from 'auth0';
import { generateRandomString, symmetricEncrypt } from "better-auth/crypto";
import { auth } from '@/lib/auth';
const auth0Client = new ManagementClient({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_SECRET!,
});
function safeDateConversion(timestamp?: string | number): Date {
if (!timestamp) return new Date();
const numericTimestamp = typeof timestamp === 'string' ? Date.parse(timestamp) : timestamp;
const milliseconds = numericTimestamp < 1000000000000 ? numericTimestamp * 1000 : numericTimestamp;
const date = new Date(milliseconds);
if (isNaN(date.getTime())) {
console.warn(`Invalid timestamp: ${timestamp}, falling back to current date`);
return new Date();
}
// Check for unreasonable dates (before 2000 or after 2100)
const year = date.getFullYear();
if (year < 2000 || year > 2100) {
console.warn(`Suspicious date year: ${year}, falling back to current date`);
return new Date();
}
return date;
}
// Helper function to generate backup codes for 2FA
async function generateBackupCodes(secret: string) {
const key = secret;
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => generateRandomString(10, "a-z", "0-9", "A-Z"))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`);
const encCodes = await symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
});
return encCodes;
}
function mapAuth0RoleToBetterAuthRole(auth0Roles: string[]) {
if (typeof auth0Roles === 'string') return auth0Roles;
if (Array.isArray(auth0Roles)) return auth0Roles.join(',');
}
// helper function to migrate password from auth0 to better auth for custom hashes and algs
async function migratePassword(auth0User: any) {
if (auth0User.password_hash) {
if (auth0User.password_hash.startsWith('$2a$') || auth0User.password_hash.startsWith('$2b$')) {
return auth0User.password_hash;
}
}
if (auth0User.custom_password_hash) {
const customHash = auth0User.custom_password_hash;
if (customHash.algorithm === 'bcrypt') {
const hash = customHash.hash.value;
if (hash.startsWith('$2a$') || hash.startsWith('$2b$')) {
return hash;
}
}
return JSON.stringify({
algorithm: customHash.algorithm,
hash: {
value: customHash.hash.value,
encoding: customHash.hash.encoding || 'utf8',
...(customHash.hash.digest && { digest: customHash.hash.digest }),
...(customHash.hash.key && {
key: {
value: customHash.hash.key.value,
encoding: customHash.hash.key.encoding || 'utf8'
}
})
},
...(customHash.salt && {
salt: {
value: customHash.salt.value,
encoding: customHash.salt.encoding || 'utf8',
position: customHash.salt.position || 'prefix'
}
}),
...(customHash.password && {
password: {
encoding: customHash.password.encoding || 'utf8'
}
}),
...(customHash.algorithm === 'scrypt' && {
keylen: customHash.keylen,
cost: customHash.cost || 16384,
blockSize: customHash.blockSize || 8,
parallelization: customHash.parallelization || 1
})
});
}
return null;
}
async function migrateMFAFactors(auth0User: any, userId: string | undefined, ctx: any) {
if (!userId || !auth0User.mfa_factors || !Array.isArray(auth0User.mfa_factors)) {
return;
}
for (const factor of auth0User.mfa_factors) {
try {
if (factor.totp && factor.totp.secret) {
await ctx.adapter.create({
model: "twoFactor",
data: {
userId: userId,
secret: factor.totp.secret,
backupCodes: await generateBackupCodes(factor.totp.secret)
}
});
}
} catch (error) {
console.error(`Failed to migrate MFA factor for user ${userId}:`, error);
}
}
}
async function migrateOAuthAccounts(auth0User: any, userId: string | undefined, ctx: any) {
if (!userId || !auth0User.identities || !Array.isArray(auth0User.identities)) {
return;
}
for (const identity of auth0User.identities) {
try {
const providerId = identity.provider === 'auth0' ? "credential" : identity.provider.split("-")[0];
await ctx.adapter.create({
model: "account",
data: {
id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`,
userId: userId,
password: await migratePassword(auth0User),
providerId: providerId || identity.provider,
accountId: identity.user_id,
accessToken: identity.access_token,
tokenType: identity.token_type,
refreshToken: identity.refresh_token,
accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined,
// if you are enterprise user, you can get the refresh tokens or all the tokensets - auth0Client.users.getAllTokensets
refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined,
scope: identity.scope,
idToken: identity.id_token,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at)
},
forceAllowId: true
}).catch((error: Error) => {
console.error(`Failed to create OAuth account for user ${userId} with provider ${providerId}:`, error);
return ctx.adapter.create({
// Try creating without optional fields if the first attempt failed
model: "account",
data: {
id: `${auth0User.user_id}|${identity.provider}|${identity.user_id}`,
userId: userId,
password: migratePassword(auth0User),
providerId: providerId,
accountId: identity.user_id,
accessToken: identity.access_token,
tokenType: identity.token_type,
refreshToken: identity.refresh_token,
accessTokenExpiresAt: identity.expires_in ? new Date(Date.now() + identity.expires_in * 1000) : undefined,
refreshTokenExpiresAt: identity.refresh_token_expires_in ? new Date(Date.now() + identity.refresh_token_expires_in * 1000) : undefined,
scope: identity.scope,
idToken: identity.id_token,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at)
},
forceAllowId: true
});
});
console.log(`Successfully migrated OAuth account for user ${userId} with provider ${providerId}`);
} catch (error) {
console.error(`Failed to migrate OAuth account for user ${userId}:`, error);
}
}
}
async function migrateOrganizations(ctx: any) {
try {
const organizations = await auth0Client.organizations.getAll();
for (const org of organizations.data || []) {
try {
await ctx.adapter.create({
model: "organization",
data: {
id: org.id,
name: org.display_name || org.id,
slug: (org.display_name || org.id).toLowerCase().replace(/[^a-z0-9]/g, '-'),
logo: org.branding?.logo_url,
metadata: JSON.stringify(org.metadata || {}),
createdAt: safeDateConversion(org.created_at),
},
forceAllowId: true
});
const members = await auth0Client.organizations.getMembers({ id: org.id });
for (const member of members.data || []) {
try {
const userRoles = await auth0Client.organizations.getMemberRoles({
id: org.id,
user_id: member.user_id
});
const role = mapAuth0RoleToBetterAuthRole(userRoles.data?.map(r => r.name) || []);
await ctx.adapter.create({
model: "member",
data: {
id: `${org.id}|${member.user_id}`,
organizationId: org.id,
userId: member.user_id,
role: role,
createdAt: new Date()
},
forceAllowId: true
});
console.log(`Successfully migrated member ${member.user_id} for organization ${org.display_name || org.id}`);
} catch (error) {
console.error(`Failed to migrate member ${member.user_id} for organization ${org.display_name || org.id}:`, error);
}
}
console.log(`Successfully migrated organization: ${org.display_name || org.id}`);
} catch (error) {
console.error(`Failed to migrate organization ${org.display_name || org.id}:`, error);
}
}
console.log('Organization migration completed');
} catch (error) {
console.error('Failed to migrate organizations:', error);
}
}
async function migrateFromAuth0() {
try {
const ctx = await auth.$context;
const isAdminEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "admin");
const isUsernameEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "username");
const isOrganizationEnabled = ctx.options?.plugins?.find(plugin => plugin.id === "organization");
const perPage = 100;
const auth0Users: any[] = [];
let pageNumber = 0;
while (true) {
try {
const params = {
per_page: perPage,
page: pageNumber,
include_totals: true,
};
const response = (await auth0Client.users.getAll(params)).data as any;
const users = response.users || [];
if (users.length === 0) break;
auth0Users.push(...users);
pageNumber++;
if (users.length < perPage) break;
} catch (error) {
console.error('Error fetching users:', error);
break;
}
}
console.log(`Found ${auth0Users.length} users to migrate`);
for (const auth0User of auth0Users) {
try {
// Determine if this is a password-based or OAuth user
const isOAuthUser = auth0User.identities?.some((identity: any) => identity.provider !== 'auth0');
// Base user data that's common for both types
const baseUserData = {
id: auth0User.user_id,
email: auth0User.email,
emailVerified: auth0User.email_verified || false,
name: auth0User.name || auth0User.nickname,
image: auth0User.picture,
createdAt: safeDateConversion(auth0User.created_at),
updatedAt: safeDateConversion(auth0User.updated_at),
...(isAdminEnabled ? {
banned: auth0User.blocked || false,
role: mapAuth0RoleToBetterAuthRole(auth0User.roles || []),
} : {}),
...(isUsernameEnabled ? {
username: auth0User.username || auth0User.nickname,
} : {}),
};
const createdUser = await ctx.adapter.create({
model: "user",
data: {
...baseUserData,
},
forceAllowId: true
});
if (!createdUser?.id) {
throw new Error('Failed to create user');
}
await migrateOAuthAccounts(auth0User, createdUser.id, ctx)
console.log(`Successfully migrated user: ${auth0User.email}`);
} catch (error) {
console.error(`Failed to migrate user ${auth0User.email}:`, error);
}
}
if (isOrganizationEnabled) {
await migrateOrganizations(ctx);
}
// the reset of migration will be here.
console.log('Migration completed successfully');
} catch (error) {
console.error('Migration failed:', error);
throw error;
}
}
migrateFromAuth0()
.then(() => {
console.log('Migration completed');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});
```
Make sure to replace the Auth0 environment variables with your own values:
- `AUTH0_DOMAIN`
- `AUTH0_CLIENT_ID`
- `AUTH0_SECRET`
</Step>
<Step>
### Run the migration
Run the migration script:
```sh
bun run scripts/migrate-auth0.ts # or use your preferred runtime
```
<Callout type="warning">
Important considerations:
1. Test the migration in a development environment first
2. Monitor the migration process for any errors
3. Verify the migrated data in Better Auth before proceeding
4. Keep Auth0 installed and configured until the migration is complete
5. The script handles bcrypt password hashes by default. For custom password hashing algorithms, you'll need to modify the `migratePassword` function
</Callout>
</Step>
<Step>
### Change password hashing algorithm
By default, Better Auth uses the `scrypt` algorithm to hash passwords. Since Auth0 uses `bcrypt`, you'll need to configure Better Auth to use bcrypt for password verification.
First, install bcrypt:
```bash
npm install bcrypt
npm install -D @types/bcrypt
```
Then update your auth configuration:
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
export const auth = betterAuth({
emailAndPassword: {
password: {
hash: async (password) => {
return await bcrypt.hash(password, 10);
},
verify: async ({ hash, password }) => {
return await bcrypt.compare(password, hash);
}
}
}
})
```
</Step>
<Step>
### Verify the migration
After running the migration, verify that:
1. All users have been properly migrated
2. Social connections are working
3. Password-based authentication is working
4. Two-factor authentication settings are preserved (if enabled)
5. User roles and permissions are correctly mapped
</Step>
<Step>
### Update your components
Now that the data is migrated, update your components to use Better Auth. Here's an example for the sign-in component:
```tsx title="components/auth/sign-in.tsx"
import { authClient } from "better-auth/client";
export const SignIn = () => {
const handleSignIn = async () => {
const { data, error } = await authClient.signIn.email({
email: "[email protected]",
password: "helloworld",
});
if (error) {
console.error(error);
return;
}
// Handle successful sign in
};
return (
<form onSubmit={handleSignIn}>
<button type="submit">Sign in</button>
</form>
);
};
```
</Step>
<Step>
### Update the middleware
Replace your Auth0 middleware with Better Auth's middleware:
```ts title="middleware.ts"
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
const { pathname } = request.nextUrl;
if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
if (!sessionCookie && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard", "/login", "/signup"],
};
```
</Step>
<Step>
### Remove Auth0 Dependencies
Once you've verified everything is working correctly with Better Auth, remove Auth0:
```bash
npm remove @auth0/auth0-react @auth0/auth0-spa-js @auth0/nextjs-auth0
```
</Step>
</Steps>
## Additional Considerations
### Password Migration
The migration script handles bcrypt password hashes by default. If you're using custom password hashing algorithms in Auth0, you'll need to modify the `migratePassword` function in the migration script to handle your specific case.
### Role Mapping
The script includes a basic role mapping function (`mapAuth0RoleToBetterAuthRole`). Customize this function based on your Auth0 roles and Better Auth role requirements.
### Rate Limiting
The migration script includes pagination to handle large numbers of users. Adjust the `perPage` value based on your needs and Auth0's rate limits.
## Wrapping Up
Now! You've successfully migrated from Auth0 to Better Auth.
Better Auth offers greater flexibility and more features—be sure to explore the [documentation](/docs) to unlock its full potential.
```
--------------------------------------------------------------------------------
/packages/better-auth/src/db/internal-adapter.ts:
--------------------------------------------------------------------------------
```typescript
import type {
AuthContext,
BetterAuthOptions,
InternalAdapter,
} from "@better-auth/core";
import {
getCurrentAdapter,
getCurrentAuthContext,
runWithTransaction,
} from "@better-auth/core/context";
import type { DBAdapter, Where } from "@better-auth/core/db/adapter";
import type { InternalLogger } from "@better-auth/core/env";
import {
type Account,
type Session,
type User,
type Verification,
} from "../types";
import { generateId } from "../utils";
import { getDate } from "../utils/date";
import { getIp } from "../utils/get-request-ip";
import { safeJSONParse } from "../utils/json";
import { parseSessionOutput, parseUserOutput } from "./schema";
import { getWithHooks } from "./with-hooks";
export const createInternalAdapter = (
adapter: DBAdapter<BetterAuthOptions>,
ctx: {
options: Omit<BetterAuthOptions, "logger">;
logger: InternalLogger;
hooks: Exclude<BetterAuthOptions["databaseHooks"], undefined>[];
generateId: AuthContext["generateId"];
},
): InternalAdapter => {
const logger = ctx.logger;
const options = ctx.options;
const secondaryStorage = options.secondaryStorage;
const sessionExpiration = options.session?.expiresIn || 60 * 60 * 24 * 7; // 7 days
const {
createWithHooks,
updateWithHooks,
updateManyWithHooks,
deleteWithHooks,
deleteManyWithHooks,
} = getWithHooks(adapter, ctx);
async function refreshUserSessions(user: User) {
if (!secondaryStorage) return;
const listRaw = await secondaryStorage.get(`active-sessions-${user.id}`);
if (!listRaw) return;
const now = Date.now();
const list =
safeJSONParse<{ token: string; expiresAt: number }[]>(listRaw) || [];
const validSessions = list.filter((s) => s.expiresAt > now);
await Promise.all(
validSessions.map(async ({ token }) => {
const cached = await secondaryStorage.get(token);
if (!cached) return;
const parsed = safeJSONParse<{ session: Session; user: User }>(cached);
if (!parsed) return;
const sessionTTL = Math.max(
Math.floor(new Date(parsed.session.expiresAt).getTime() - now) / 1000,
0,
);
await secondaryStorage.set(
token,
JSON.stringify({
session: parsed.session,
user,
}),
Math.floor(sessionTTL),
);
}),
);
}
return {
createOAuthUser: async (
user: Omit<User, "id" | "createdAt" | "updatedAt">,
account: Omit<Account, "userId" | "id" | "createdAt" | "updatedAt"> &
Partial<Account>,
) => {
return runWithTransaction(adapter, async () => {
const createdUser = await createWithHooks(
{
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...user,
},
"user",
undefined,
);
const createdAccount = await createWithHooks(
{
...account,
userId: createdUser!.id,
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
},
"account",
undefined,
);
return {
user: createdUser,
account: createdAccount,
};
});
},
createUser: async <T>(
user: Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> &
Partial<User> &
Record<string, any>,
) => {
const createdUser = await createWithHooks(
{
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...user,
email: user.email?.toLowerCase(),
},
"user",
undefined,
);
return createdUser as T & User;
},
createAccount: async <T extends Record<string, any>>(
account: Omit<Account, "id" | "createdAt" | "updatedAt"> &
Partial<Account> &
T,
) => {
const createdAccount = await createWithHooks(
{
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...account,
},
"account",
undefined,
);
return createdAccount as T & Account;
},
listSessions: async (userId: string) => {
if (secondaryStorage) {
const currentList = await secondaryStorage.get(
`active-sessions-${userId}`,
);
if (!currentList) return [];
const list: { token: string; expiresAt: number }[] =
safeJSONParse(currentList) || [];
const now = Date.now();
const validSessions = list.filter((s) => s.expiresAt > now);
const sessions = [];
for (const session of validSessions) {
const sessionStringified = await secondaryStorage.get(session.token);
if (sessionStringified) {
const s = safeJSONParse<{
session: Session;
user: User;
}>(sessionStringified);
if (!s) return [];
const parsedSession = parseSessionOutput(ctx.options, {
...s.session,
expiresAt: new Date(s.session.expiresAt),
});
sessions.push(parsedSession);
}
}
return sessions;
}
const sessions = await (
await getCurrentAdapter(adapter)
).findMany<Session>({
model: "session",
where: [
{
field: "userId",
value: userId,
},
],
});
return sessions;
},
listUsers: async (
limit?: number,
offset?: number,
sortBy?: {
field: string;
direction: "asc" | "desc";
},
where?: Where[],
) => {
const users = await (await getCurrentAdapter(adapter)).findMany<User>({
model: "user",
limit,
offset,
sortBy,
where,
});
return users;
},
countTotalUsers: async (where?: Where[]) => {
const total = await (await getCurrentAdapter(adapter)).count({
model: "user",
where,
});
if (typeof total === "string") {
return parseInt(total);
}
return total;
},
deleteUser: async (userId: string) => {
if (secondaryStorage) {
await secondaryStorage.delete(`active-sessions-${userId}`);
}
if (!secondaryStorage || options.session?.storeSessionInDatabase) {
await deleteManyWithHooks(
[
{
field: "userId",
value: userId,
},
],
"session",
undefined,
);
}
await deleteManyWithHooks(
[
{
field: "userId",
value: userId,
},
],
"account",
undefined,
);
await deleteWithHooks(
[
{
field: "id",
value: userId,
},
],
"user",
undefined,
);
},
createSession: async (
userId: string,
dontRememberMe?: boolean,
override?: Partial<Session> & Record<string, any>,
overrideAll?: boolean,
) => {
const ctx = await getCurrentAuthContext();
const headers = ctx.headers || ctx.request?.headers;
const { id: _, ...rest } = override || {};
const data: Omit<Session, "id"> = {
ipAddress:
ctx.request || ctx.headers
? getIp(ctx.request || ctx.headers!, ctx.context.options) || ""
: "",
userAgent: headers?.get("user-agent") || "",
...rest,
/**
* If the user doesn't want to be remembered
* set the session to expire in 1 day.
* The cookie will be set to expire at the end of the session
*/
expiresAt: dontRememberMe
? getDate(60 * 60 * 24, "sec") // 1 day
: getDate(sessionExpiration, "sec"),
userId,
token: generateId(32),
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...(overrideAll ? rest : {}),
};
const res = await createWithHooks(
data,
"session",
secondaryStorage
? {
fn: async (sessionData) => {
/**
* store the session token for the user
* so we can retrieve it later for listing sessions
*/
const currentList = await secondaryStorage.get(
`active-sessions-${userId}`,
);
let list: { token: string; expiresAt: number }[] = [];
const now = Date.now();
if (currentList) {
list = safeJSONParse(currentList) || [];
list = list.filter((session) => session.expiresAt > now);
}
const sorted = list.sort((a, b) => a.expiresAt - b.expiresAt);
let furthestSessionExp = sorted.at(-1)?.expiresAt;
sorted.push({
token: data.token,
expiresAt: data.expiresAt.getTime(),
});
if (
!furthestSessionExp ||
furthestSessionExp < data.expiresAt.getTime()
) {
furthestSessionExp = data.expiresAt.getTime();
}
const furthestSessionTTL = Math.max(
Math.floor((furthestSessionExp - now) / 1000),
0,
);
if (furthestSessionTTL > 0) {
await secondaryStorage.set(
`active-sessions-${userId}`,
JSON.stringify(sorted),
furthestSessionTTL,
);
}
const user = await adapter.findOne<User>({
model: "user",
where: [
{
field: "id",
value: userId,
},
],
});
const sessionTTL = Math.max(
Math.floor((data.expiresAt.getTime() - now) / 1000),
0,
);
if (sessionTTL > 0) {
await secondaryStorage.set(
data.token,
JSON.stringify({
session: sessionData,
user,
}),
sessionTTL,
);
}
return sessionData;
},
executeMainFn: options.session?.storeSessionInDatabase,
}
: undefined,
);
return res as Session;
},
findSession: async (
token: string,
): Promise<{
session: Session & Record<string, any>;
user: User & Record<string, any>;
} | null> => {
if (secondaryStorage) {
const sessionStringified = await secondaryStorage.get(token);
if (!sessionStringified && !options.session?.storeSessionInDatabase) {
return null;
}
if (sessionStringified) {
const s = safeJSONParse<{
session: Session;
user: User;
}>(sessionStringified);
if (!s) return null;
const parsedSession = parseSessionOutput(ctx.options, {
...s.session,
expiresAt: new Date(s.session.expiresAt),
createdAt: new Date(s.session.createdAt),
updatedAt: new Date(s.session.updatedAt),
});
const parsedUser = parseUserOutput(ctx.options, {
...s.user,
createdAt: new Date(s.user.createdAt),
updatedAt: new Date(s.user.updatedAt),
});
return {
session: parsedSession,
user: parsedUser,
};
}
}
const session = await (await getCurrentAdapter(adapter)).findOne<Session>(
{
model: "session",
where: [
{
value: token,
field: "token",
},
],
},
);
if (!session) {
return null;
}
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
value: session.userId,
field: "id",
},
],
});
if (!user) {
return null;
}
const parsedSession = parseSessionOutput(ctx.options, session);
const parsedUser = parseUserOutput(ctx.options, user);
return {
session: parsedSession,
user: parsedUser,
};
},
findSessions: async (sessionTokens: string[]) => {
if (secondaryStorage) {
const sessions: {
session: Session;
user: User;
}[] = [];
for (const sessionToken of sessionTokens) {
const sessionStringified = await secondaryStorage.get(sessionToken);
if (sessionStringified) {
const s = safeJSONParse<{
session: Session;
user: User;
}>(sessionStringified);
if (!s) return [];
const session = {
session: {
...s.session,
expiresAt: new Date(s.session.expiresAt),
},
user: {
...s.user,
createdAt: new Date(s.user.createdAt),
updatedAt: new Date(s.user.updatedAt),
},
} as {
session: Session;
user: User;
};
sessions.push(session);
}
}
return sessions;
}
const sessions = await (
await getCurrentAdapter(adapter)
).findMany<Session>({
model: "session",
where: [
{
field: "token",
value: sessionTokens,
operator: "in",
},
],
});
const userIds = sessions.map((session) => {
return session.userId;
});
if (!userIds.length) return [];
const users = await (await getCurrentAdapter(adapter)).findMany<User>({
model: "user",
where: [
{
field: "id",
value: userIds,
operator: "in",
},
],
});
return sessions.map((session) => {
const user = users.find((u) => u.id === session.userId);
if (!user) return null;
return {
session,
user,
};
}) as {
session: Session;
user: User;
}[];
},
updateSession: async (
sessionToken: string,
session: Partial<Session> & Record<string, any>,
) => {
const updatedSession = await updateWithHooks<Session>(
session,
[{ field: "token", value: sessionToken }],
"session",
secondaryStorage
? {
async fn(data) {
const currentSession = await secondaryStorage.get(sessionToken);
let updatedSession: Session | null = null;
if (currentSession) {
const parsedSession = safeJSONParse<{
session: Session;
user: User;
}>(currentSession);
if (!parsedSession) return null;
updatedSession = {
...parsedSession.session,
...data,
};
return updatedSession;
} else {
return null;
}
},
executeMainFn: options.session?.storeSessionInDatabase,
}
: undefined,
);
return updatedSession;
},
deleteSession: async (token: string) => {
if (secondaryStorage) {
// remove the session from the active sessions list
const data = await secondaryStorage.get(token);
if (data) {
const { session } =
safeJSONParse<{
session: Session;
user: User;
}>(data) ?? {};
if (!session) {
logger.error("Session not found in secondary storage");
return;
}
const userId = session.userId;
const currentList = await secondaryStorage.get(
`active-sessions-${userId}`,
);
if (currentList) {
let list: { token: string; expiresAt: number }[] =
safeJSONParse(currentList) || [];
const now = Date.now();
const filtered = list.filter(
(session) => session.expiresAt > now && session.token !== token,
);
const sorted = filtered.sort((a, b) => a.expiresAt - b.expiresAt);
const furthestSessionExp = sorted.at(-1)?.expiresAt;
if (
filtered.length > 0 &&
furthestSessionExp &&
furthestSessionExp > Date.now()
) {
await secondaryStorage.set(
`active-sessions-${userId}`,
JSON.stringify(filtered),
Math.floor((furthestSessionExp - now) / 1000),
);
} else {
await secondaryStorage.delete(`active-sessions-${userId}`);
}
} else {
logger.error("Active sessions list not found in secondary storage");
}
}
await secondaryStorage.delete(token);
if (
!options.session?.storeSessionInDatabase ||
ctx.options.session?.preserveSessionInDatabase
) {
return;
}
}
await (await getCurrentAdapter(adapter)).delete<Session>({
model: "session",
where: [
{
field: "token",
value: token,
},
],
});
},
deleteAccounts: async (userId: string) => {
await deleteManyWithHooks(
[
{
field: "userId",
value: userId,
},
],
"account",
undefined,
);
},
deleteAccount: async (accountId: string) => {
await deleteWithHooks(
[{ field: "id", value: accountId }],
"account",
undefined,
);
},
deleteSessions: async (userIdOrSessionTokens: string | string[]) => {
if (secondaryStorage) {
if (typeof userIdOrSessionTokens === "string") {
const activeSession = await secondaryStorage.get(
`active-sessions-${userIdOrSessionTokens}`,
);
const sessions = activeSession
? safeJSONParse<{ token: string }[]>(activeSession)
: [];
if (!sessions) return;
for (const session of sessions) {
await secondaryStorage.delete(session.token);
}
} else {
for (const sessionToken of userIdOrSessionTokens) {
const session = await secondaryStorage.get(sessionToken);
if (session) {
await secondaryStorage.delete(sessionToken);
}
}
}
if (
!options.session?.storeSessionInDatabase ||
ctx.options.session?.preserveSessionInDatabase
) {
return;
}
}
await deleteManyWithHooks(
[
{
field: Array.isArray(userIdOrSessionTokens) ? "token" : "userId",
value: userIdOrSessionTokens,
operator: Array.isArray(userIdOrSessionTokens) ? "in" : undefined,
},
],
"session",
undefined,
);
},
findOAuthUser: async (
email: string,
accountId: string,
providerId: string,
) => {
// we need to find account first to avoid missing user if the email changed with the provider for the same account
const account = await (await getCurrentAdapter(adapter))
.findMany<Account>({
model: "account",
where: [
{
value: accountId,
field: "accountId",
},
],
})
.then((accounts) => {
return accounts.find((a) => a.providerId === providerId);
});
if (account) {
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
value: account.userId,
field: "id",
},
],
});
if (user) {
return {
user,
accounts: [account],
};
} else {
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
value: email.toLowerCase(),
field: "email",
},
],
});
if (user) {
return {
user,
accounts: [account],
};
}
return null;
}
} else {
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
value: email.toLowerCase(),
field: "email",
},
],
});
if (user) {
const accounts = await (
await getCurrentAdapter(adapter)
).findMany<Account>({
model: "account",
where: [
{
value: user.id,
field: "userId",
},
],
});
return {
user,
accounts: accounts || [],
};
} else {
return null;
}
}
},
findUserByEmail: async (
email: string,
options?: { includeAccounts: boolean },
) => {
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
value: email.toLowerCase(),
field: "email",
},
],
});
if (!user) return null;
if (options?.includeAccounts) {
const accounts = await (
await getCurrentAdapter(adapter)
).findMany<Account>({
model: "account",
where: [
{
value: user.id,
field: "userId",
},
],
});
return {
user,
accounts,
};
}
return {
user,
accounts: [],
};
},
findUserById: async (userId: string) => {
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [
{
field: "id",
value: userId,
},
],
});
return user;
},
linkAccount: async (
account: Omit<Account, "id" | "createdAt" | "updatedAt"> &
Partial<Account>,
) => {
const _account = await createWithHooks(
{
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...account,
},
"account",
undefined,
);
return _account;
},
updateUser: async (
userId: string,
data: Partial<User> & Record<string, any>,
) => {
const user = await updateWithHooks<User>(
data,
[
{
field: "id",
value: userId,
},
],
"user",
undefined,
);
await refreshUserSessions(user);
await refreshUserSessions(user);
return user;
},
updateUserByEmail: async (
email: string,
data: Partial<User & Record<string, any>>,
) => {
const user = await updateWithHooks<User>(
data,
[
{
field: "email",
value: email.toLowerCase(),
},
],
"user",
undefined,
);
await refreshUserSessions(user);
await refreshUserSessions(user);
return user;
},
updatePassword: async (userId: string, password: string) => {
await updateManyWithHooks(
{
password,
},
[
{
field: "userId",
value: userId,
},
{
field: "providerId",
value: "credential",
},
],
"account",
undefined,
);
},
findAccounts: async (userId: string) => {
const accounts = await (
await getCurrentAdapter(adapter)
).findMany<Account>({
model: "account",
where: [
{
field: "userId",
value: userId,
},
],
});
return accounts;
},
findAccount: async (accountId: string) => {
const account = await (await getCurrentAdapter(adapter)).findOne<Account>(
{
model: "account",
where: [
{
field: "accountId",
value: accountId,
},
],
},
);
return account;
},
findAccountByProviderId: async (accountId: string, providerId: string) => {
const account = await (await getCurrentAdapter(adapter)).findOne<Account>(
{
model: "account",
where: [
{
field: "accountId",
value: accountId,
},
{
field: "providerId",
value: providerId,
},
],
},
);
return account;
},
findAccountByUserId: async (userId: string) => {
const account = await (
await getCurrentAdapter(adapter)
).findMany<Account>({
model: "account",
where: [
{
field: "userId",
value: userId,
},
],
});
return account;
},
updateAccount: async (id: string, data: Partial<Account>) => {
const account = await updateWithHooks<Account>(
data,
[{ field: "id", value: id }],
"account",
undefined,
);
return account;
},
createVerificationValue: async (
data: Omit<Verification, "createdAt" | "id" | "updatedAt"> &
Partial<Verification>,
) => {
const verification = await createWithHooks(
{
// todo: we should remove auto setting createdAt and updatedAt in the next major release, since the db generators already handle that
createdAt: new Date(),
updatedAt: new Date(),
...data,
},
"verification",
undefined,
);
return verification as Verification;
},
findVerificationValue: async (identifier: string) => {
const verification = await (
await getCurrentAdapter(adapter)
).findMany<Verification>({
model: "verification",
where: [
{
field: "identifier",
value: identifier,
},
],
sortBy: {
field: "createdAt",
direction: "desc",
},
limit: 1,
});
if (!options.verification?.disableCleanup) {
await (await getCurrentAdapter(adapter)).deleteMany({
model: "verification",
where: [
{
field: "expiresAt",
value: new Date(),
operator: "lt",
},
],
});
}
const lastVerification = verification[0];
return lastVerification as Verification | null;
},
deleteVerificationValue: async (id: string) => {
await (await getCurrentAdapter(adapter)).delete<Verification>({
model: "verification",
where: [
{
field: "id",
value: id,
},
],
});
},
deleteVerificationByIdentifier: async (identifier: string) => {
await (await getCurrentAdapter(adapter)).delete<Verification>({
model: "verification",
where: [
{
field: "identifier",
value: identifier,
},
],
});
},
updateVerificationValue: async (
id: string,
data: Partial<Verification>,
) => {
const verification = await updateWithHooks<Verification>(
data,
[{ field: "id", value: id }],
"verification",
undefined,
);
return verification;
},
};
};
```
--------------------------------------------------------------------------------
/packages/better-auth/src/plugins/phone-number/index.ts:
--------------------------------------------------------------------------------
```typescript
import type { BetterAuthPlugin } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
import type { BetterAuthPluginDBSchema } from "@better-auth/core/db";
import { BASE_ERROR_CODES } from "@better-auth/core/error";
import { APIError } from "better-call";
import * as z from "zod";
import { getSessionFromCtx } from "../../api";
import { setSessionCookie } from "../../cookies";
import { generateRandomString } from "../../crypto/random";
import { mergeSchema } from "../../db/schema";
import type { User } from "../../types";
import type { InferOptionSchema } from "../../types/plugins";
import { getDate } from "../../utils/date";
import { ERROR_CODES } from "./phone-number-error";
export interface UserWithPhoneNumber extends User {
phoneNumber: string;
phoneNumberVerified: boolean;
}
function generateOTP(size: number) {
return generateRandomString(size, "0-9");
}
export interface PhoneNumberOptions {
/**
* Length of the OTP code
* @default 6
*/
otpLength?: number;
/**
* Send OTP code to the user
*
* @param phoneNumber
* @param code
* @returns
*/
sendOTP: (
data: { phoneNumber: string; code: string },
request?: Request,
) => Promise<void> | void;
/**
* a callback to send otp on user requesting to reset their password
*
* @param data - contains phone number and code
* @param request - the request object
* @returns
*/
sendPasswordResetOTP?: (
data: { phoneNumber: string; code: string },
request?: Request,
) => Promise<void> | void;
/**
* Expiry time of the OTP code in seconds
* @default 300
*/
expiresIn?: number;
/**
* Function to validate phone number
*
* by default any string is accepted
*/
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
/**
* Require a phone number verification before signing in
*
* @default false
*/
requireVerification?: boolean;
/**
* Callback when phone number is verified
*/
callbackOnVerification?: (
data: {
phoneNumber: string;
user: UserWithPhoneNumber;
},
request?: Request,
) => void | Promise<void>;
/**
* Sign up user after phone number verification
*
* the user will be signed up with the temporary email
* and the phone number will be updated after verification
*/
signUpOnVerification?: {
/**
* When a user signs up, a temporary email will be need to be created
* to sign up the user. This function should return a temporary email
* for the user given the phone number
*
* @param phoneNumber
* @returns string (temporary email)
*/
getTempEmail: (phoneNumber: string) => string;
/**
* When a user signs up, a temporary name will be need to be created
* to sign up the user. This function should return a temporary name
* for the user given the phone number
*
* @param phoneNumber
* @returns string (temporary name)
*
* @default phoneNumber - the phone number will be used as the name
*/
getTempName?: (phoneNumber: string) => string;
};
/**
* Custom schema for the admin plugin
*/
schema?: InferOptionSchema<typeof schema>;
/**
* Allowed attempts for the OTP code
* @default 3
*/
allowedAttempts?: number;
}
export const phoneNumber = (options?: PhoneNumberOptions) => {
const opts = {
expiresIn: options?.expiresIn || 300,
otpLength: options?.otpLength || 6,
...options,
phoneNumber: "phoneNumber",
phoneNumberVerified: "phoneNumberVerified",
code: "code",
createdAt: "createdAt",
};
return {
id: "phone-number",
endpoints: {
/**
* ### Endpoint
*
* POST `/sign-in/phone-number`
*
* ### API Methods
*
* **server:**
* `auth.api.signInPhoneNumber`
*
* **client:**
* `authClient.signIn.phoneNumber`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/phone-number#api-method-sign-in-phone-number)
*/
signInPhoneNumber: createAuthEndpoint(
"/sign-in/phone-number",
{
method: "POST",
body: z.object({
phoneNumber: z.string().meta({
description: 'Phone number to sign in. Eg: "+1234567890"',
}),
password: z.string().meta({
description: "Password to use for sign in.",
}),
rememberMe: z
.boolean()
.meta({
description: "Remember the session. Eg: true",
})
.optional(),
}),
metadata: {
openapi: {
summary: "Sign in with phone number",
description: "Use this endpoint to sign in with phone number",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
user: {
$ref: "#/components/schemas/User",
},
session: {
$ref: "#/components/schemas/Session",
},
},
},
},
},
},
400: {
description: "Invalid phone number or password",
},
},
},
},
},
async (ctx) => {
const { password, phoneNumber } = ctx.body;
if (opts.phoneNumberValidator) {
const isValidNumber = await opts.phoneNumberValidator(
ctx.body.phoneNumber,
);
if (!isValidNumber) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_PHONE_NUMBER,
});
}
}
const user = await ctx.context.adapter.findOne<UserWithPhoneNumber>({
model: "user",
where: [
{
field: "phoneNumber",
value: phoneNumber,
},
],
});
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
});
}
if (opts.requireVerification) {
if (!user.phoneNumberVerified) {
const otp = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: otp,
identifier: phoneNumber,
expiresAt: getDate(opts.expiresIn, "sec"),
});
await opts.sendOTP?.(
{
phoneNumber,
code: otp,
},
ctx.request,
);
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.PHONE_NUMBER_NOT_VERIFIED,
});
}
}
const accounts =
await ctx.context.internalAdapter.findAccountByUserId(user.id);
const credentialAccount = accounts.find(
(a) => a.providerId === "credential",
);
if (!credentialAccount) {
ctx.context.logger.error("Credential account not found", {
phoneNumber,
});
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
});
}
const currentPassword = credentialAccount?.password;
if (!currentPassword) {
ctx.context.logger.error("Password not found", { phoneNumber });
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.UNEXPECTED_ERROR,
});
}
const validPassword = await ctx.context.password.verify({
hash: currentPassword,
password,
});
if (!validPassword) {
ctx.context.logger.error("Invalid password");
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
});
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
ctx.body.rememberMe === false,
);
if (!session) {
ctx.context.logger.error("Failed to create session");
throw new APIError("UNAUTHORIZED", {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
await setSessionCookie(
ctx,
{
session,
user: user,
},
ctx.body.rememberMe === false,
);
return ctx.json({
token: session.token,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
phoneNumber: user.phoneNumber,
phoneNumberVerified: user.phoneNumberVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
} as UserWithPhoneNumber,
});
},
),
/**
* ### Endpoint
*
* POST `/phone-number/send-otp`
*
* ### API Methods
*
* **server:**
* `auth.api.sendPhoneNumberOTP`
*
* **client:**
* `authClient.phoneNumber.sendOtp`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/phone-number#api-method-phone-number-send-otp)
*/
sendPhoneNumberOTP: createAuthEndpoint(
"/phone-number/send-otp",
{
method: "POST",
body: z.object({
phoneNumber: z.string().meta({
description: 'Phone number to send OTP. Eg: "+1234567890"',
}),
}),
metadata: {
openapi: {
summary: "Send OTP to phone number",
description: "Use this endpoint to send OTP to phone number",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: {
type: "string",
},
},
},
},
},
},
},
},
},
},
async (ctx) => {
if (!options?.sendOTP) {
ctx.context.logger.warn("sendOTP not implemented");
throw new APIError("NOT_IMPLEMENTED", {
message: "sendOTP not implemented",
});
}
if (opts.phoneNumberValidator) {
const isValidNumber = await opts.phoneNumberValidator(
ctx.body.phoneNumber,
);
if (!isValidNumber) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_PHONE_NUMBER,
});
}
}
const code = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: `${code}:0`,
identifier: ctx.body.phoneNumber,
expiresAt: getDate(opts.expiresIn, "sec"),
});
await options.sendOTP(
{
phoneNumber: ctx.body.phoneNumber,
code,
},
ctx.request,
);
return ctx.json({ message: "code sent" });
},
),
/**
* ### Endpoint
*
* POST `/phone-number/verify`
*
* ### API Methods
*
* **server:**
* `auth.api.verifyPhoneNumber`
*
* **client:**
* `authClient.phoneNumber.verify`
*
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/phone-number#api-method-phone-number-verify)
*/
verifyPhoneNumber: createAuthEndpoint(
"/phone-number/verify",
{
method: "POST",
body: z.object({
/**
* Phone number
*/
phoneNumber: z.string().meta({
description: 'Phone number to verify. Eg: "+1234567890"',
}),
/**
* OTP code
*/
code: z.string().meta({
description: 'OTP code. Eg: "123456"',
}),
/**
* Disable session creation after verification
* @default false
*/
disableSession: z
.boolean()
.meta({
description:
"Disable session creation after verification. Eg: false",
})
.optional(),
/**
* This checks if there is a session already
* and updates the phone number with the provided
* phone number
*/
updatePhoneNumber: z
.boolean()
.meta({
description:
"Check if there is a session and update the phone number. Eg: true",
})
.optional(),
}),
metadata: {
openapi: {
summary: "Verify phone number",
description: "Use this endpoint to verify phone number",
responses: {
"200": {
description: "Phone number verified successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description:
"Indicates if the verification was successful",
enum: [true],
},
token: {
type: "string",
nullable: true,
description:
"Session token if session is created, null if disableSession is true or no session is created",
},
user: {
type: "object",
nullable: true,
properties: {
id: {
type: "string",
description: "Unique identifier of the user",
},
email: {
type: "string",
format: "email",
nullable: true,
description: "User's email address",
},
emailVerified: {
type: "boolean",
nullable: true,
description: "Whether the email is verified",
},
name: {
type: "string",
nullable: true,
description: "User's name",
},
image: {
type: "string",
format: "uri",
nullable: true,
description: "User's profile image URL",
},
phoneNumber: {
type: "string",
description: "User's phone number",
},
phoneNumberVerified: {
type: "boolean",
description:
"Whether the phone number is verified",
},
createdAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the user was created",
},
updatedAt: {
type: "string",
format: "date-time",
description:
"Timestamp when the user was last updated",
},
},
required: [
"id",
"phoneNumber",
"phoneNumberVerified",
"createdAt",
"updatedAt",
],
description:
"User object with phone number details, null if no user is created or found",
},
},
required: ["status"],
},
},
},
},
400: {
description: "Invalid OTP",
},
},
},
},
},
async (ctx) => {
const otp = await ctx.context.internalAdapter.findVerificationValue(
ctx.body.phoneNumber,
);
if (!otp || otp.expiresAt < new Date()) {
if (otp && otp.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
message: "OTP expired",
});
}
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.OTP_NOT_FOUND,
});
}
const [otpValue, attempts] = otp.value.split(":");
const allowedAttempts = options?.allowedAttempts || 3;
if (attempts && parseInt(attempts) >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationValue(otp.id);
throw new APIError("FORBIDDEN", {
message: "Too many attempts",
});
}
if (otpValue !== ctx.body.code) {
await ctx.context.internalAdapter.updateVerificationValue(otp.id, {
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
});
throw new APIError("BAD_REQUEST", {
message: "Invalid OTP",
});
}
await ctx.context.internalAdapter.deleteVerificationValue(otp.id);
if (ctx.body.updatePhoneNumber) {
const session = await getSessionFromCtx(ctx);
if (!session) {
throw new APIError("UNAUTHORIZED", {
message: BASE_ERROR_CODES.USER_NOT_FOUND,
});
}
const existingUser =
await ctx.context.adapter.findMany<UserWithPhoneNumber>({
model: "user",
where: [
{
field: "phoneNumber",
value: ctx.body.phoneNumber,
},
],
});
if (existingUser.length) {
throw ctx.error("BAD_REQUEST", {
message: ERROR_CODES.PHONE_NUMBER_EXIST,
});
}
let user = await ctx.context.internalAdapter.updateUser(
session.user.id,
{
[opts.phoneNumber]: ctx.body.phoneNumber,
[opts.phoneNumberVerified]: true,
},
);
return ctx.json({
status: true,
token: session.session.token,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
phoneNumber: user.phoneNumber,
phoneNumberVerified: user.phoneNumberVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
} as UserWithPhoneNumber,
});
}
let user = await ctx.context.adapter.findOne<UserWithPhoneNumber>({
model: "user",
where: [
{
value: ctx.body.phoneNumber,
field: opts.phoneNumber,
},
],
});
if (!user) {
if (options?.signUpOnVerification) {
user =
await ctx.context.internalAdapter.createUser<UserWithPhoneNumber>(
{
email: options.signUpOnVerification.getTempEmail(
ctx.body.phoneNumber,
),
name: options.signUpOnVerification.getTempName
? options.signUpOnVerification.getTempName(
ctx.body.phoneNumber,
)
: ctx.body.phoneNumber,
[opts.phoneNumber]: ctx.body.phoneNumber,
[opts.phoneNumberVerified]: true,
},
);
if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_USER,
});
}
}
} else {
user = await ctx.context.internalAdapter.updateUser(user.id, {
[opts.phoneNumberVerified]: true,
});
}
if (!user) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_UPDATE_USER,
});
}
await options?.callbackOnVerification?.(
{
phoneNumber: ctx.body.phoneNumber,
user,
},
ctx.request,
);
if (!ctx.body.disableSession) {
const session = await ctx.context.internalAdapter.createSession(
user.id,
);
if (!session) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
});
}
await setSessionCookie(ctx, {
session,
user,
});
return ctx.json({
status: true,
token: session.token,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
phoneNumber: user.phoneNumber,
phoneNumberVerified: user.phoneNumberVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
} as UserWithPhoneNumber,
});
}
return ctx.json({
status: true,
token: null,
user: {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
name: user.name,
image: user.image,
phoneNumber: user.phoneNumber,
phoneNumberVerified: user.phoneNumberVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
} as UserWithPhoneNumber,
});
},
),
requestPasswordResetPhoneNumber: createAuthEndpoint(
"/phone-number/request-password-reset",
{
method: "POST",
body: z.object({
phoneNumber: z.string(),
}),
metadata: {
openapi: {
description: "Request OTP for password reset via phone number",
responses: {
"200": {
description: "OTP sent successfully for password reset",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description:
"Indicates if the OTP was sent successfully",
enum: [true],
},
},
required: ["status"],
},
},
},
},
},
},
},
},
async (ctx) => {
const user = await ctx.context.adapter.findOne<UserWithPhoneNumber>({
model: "user",
where: [
{
value: ctx.body.phoneNumber,
field: opts.phoneNumber,
},
],
});
if (!user) {
throw new APIError("BAD_REQUEST", {
message: "phone number isn't registered",
});
}
const code = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: `${code}:0`,
identifier: `${ctx.body.phoneNumber}-request-password-reset`,
expiresAt: getDate(opts.expiresIn, "sec"),
});
await options?.sendPasswordResetOTP?.(
{
phoneNumber: ctx.body.phoneNumber,
code,
},
ctx.request,
);
return ctx.json({
status: true,
});
},
),
resetPasswordPhoneNumber: createAuthEndpoint(
"/phone-number/reset-password",
{
method: "POST",
body: z.object({
otp: z.string().meta({
description:
'The one time password to reset the password. Eg: "123456"',
}),
phoneNumber: z.string().meta({
description:
'The phone number to the account which intends to reset the password for. Eg: "+1234567890"',
}),
newPassword: z.string().meta({
description: `The new password. Eg: "new-and-secure-password"`,
}),
}),
metadata: {
openapi: {
description: "Reset password using phone number OTP",
responses: {
"200": {
description: "Password reset successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "boolean",
description:
"Indicates if the password was reset successfully",
enum: [true],
},
},
required: ["status"],
},
},
},
},
},
},
},
},
async (ctx) => {
const verification =
await ctx.context.internalAdapter.findVerificationValue(
`${ctx.body.phoneNumber}-request-password-reset`,
);
if (!verification) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.OTP_NOT_FOUND,
});
}
if (verification.expiresAt < new Date()) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.OTP_EXPIRED,
});
}
const [otpValue, attempts] = verification.value.split(":");
const allowedAttempts = options?.allowedAttempts || 3;
if (attempts && parseInt(attempts) >= allowedAttempts) {
await ctx.context.internalAdapter.deleteVerificationValue(
verification.id,
);
throw new APIError("FORBIDDEN", {
message: "Too many attempts",
});
}
if (ctx.body.otp !== otpValue) {
await ctx.context.internalAdapter.updateVerificationValue(
verification.id,
{
value: `${otpValue}:${parseInt(attempts || "0") + 1}`,
},
);
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.INVALID_OTP,
});
}
const user = await ctx.context.adapter.findOne<User>({
model: "user",
where: [
{
field: "phoneNumber",
value: ctx.body.phoneNumber,
},
],
});
if (!user) {
throw new APIError("BAD_REQUEST", {
message: ERROR_CODES.UNEXPECTED_ERROR,
});
}
const hashedPassword = await ctx.context.password.hash(
ctx.body.newPassword,
);
await ctx.context.internalAdapter.updatePassword(
user.id,
hashedPassword,
);
await ctx.context.internalAdapter.deleteVerificationValue(
verification.id,
);
return ctx.json({
status: true,
});
},
),
},
schema: mergeSchema(schema, options?.schema),
rateLimit: [
{
pathMatcher(path) {
return path.startsWith("/phone-number");
},
window: 60 * 1000,
max: 10,
},
],
$ERROR_CODES: ERROR_CODES,
} satisfies BetterAuthPlugin;
};
const schema = {
user: {
fields: {
phoneNumber: {
type: "string",
required: false,
unique: true,
sortable: true,
returned: true,
},
phoneNumberVerified: {
type: "boolean",
required: false,
returned: true,
input: false,
},
},
},
} satisfies BetterAuthPluginDBSchema;
```