This is page 21 of 51. 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 ├── 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 │ │ │ ├── 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 │ ├── tailwind.config.js │ ├── 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 │ │ │ │ └── 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.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.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 │ │ │ │ ├── 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 │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.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/adapters/kysely-adapter/kysely-adapter.ts: -------------------------------------------------------------------------------- ```typescript import { createAdapterFactory, type AdapterFactoryCustomizeAdapterCreator, type AdapterFactoryOptions, } from "../adapter-factory"; import type { BetterAuthOptions } from "@better-auth/core"; import type { KyselyDatabaseType } from "./types"; import { type InsertQueryBuilder, type Kysely, type RawBuilder, type UpdateQueryBuilder, } from "kysely"; import type { DBAdapterDebugLogOption, DBAdapter, Where, } from "@better-auth/core/db/adapter"; interface KyselyAdapterConfig { /** * Database type. */ type?: KyselyDatabaseType; /** * Enable debug logs for the adapter * * @default false */ debugLogs?: DBAdapterDebugLogOption; /** * Use plural for table names. * * @default false */ usePlural?: boolean; /** * Whether to execute multiple operations in a transaction. * * If the database doesn't support transactions, * set this to `false` and operations will be executed sequentially. * @default false */ transaction?: boolean; } export const kyselyAdapter = ( db: Kysely<any>, config?: KyselyAdapterConfig, ) => { let lazyOptions: BetterAuthOptions | null = null; const createCustomAdapter = ( db: Kysely<any>, ): AdapterFactoryCustomizeAdapterCreator => { return ({ getFieldName }) => { const withReturning = async ( values: Record<string, any>, builder: | InsertQueryBuilder<any, any, any> | UpdateQueryBuilder<any, string, string, any>, model: string, where: Where[], ) => { let res: any; if (config?.type === "mysql") { // This isn't good, but kysely doesn't support returning in mysql and it doesn't return the inserted id. // Change this if there is a better way. await builder.execute(); const field = values.id ? "id" : where.length > 0 && where[0]?.field ? where[0].field : "id"; if (!values.id && where.length === 0) { res = await db .selectFrom(model) .selectAll() .orderBy(getFieldName({ model, field }), "desc") .limit(1) .executeTakeFirst(); return res; } const value = values[field] || where[0]?.value; res = await db .selectFrom(model) .selectAll() .orderBy(getFieldName({ model, field }), "desc") .where(getFieldName({ model, field }), "=", value) .limit(1) .executeTakeFirst(); return res; } if (config?.type === "mssql") { res = await builder.outputAll("inserted").executeTakeFirst(); return res; } res = await builder.returningAll().executeTakeFirst(); return res; }; function convertWhereClause(model: string, w?: Where[]) { if (!w) return { and: null, or: null, }; const conditions = { and: [] as any[], or: [] as any[], }; w.forEach((condition) => { let { field: _field, value: _value, operator = "=", connector = "AND", } = condition; let value: any = _value; let field: string | RawBuilder<unknown> = getFieldName({ model, field: _field, }); const expr = (eb: any) => { if (operator.toLowerCase() === "in") { return eb(field, "in", Array.isArray(value) ? value : [value]); } if (operator.toLowerCase() === "not_in") { return eb( field, "not in", Array.isArray(value) ? value : [value], ); } if (operator === "contains") { return eb(field, "like", `%${value}%`); } if (operator === "starts_with") { return eb(field, "like", `${value}%`); } if (operator === "ends_with") { return eb(field, "like", `%${value}`); } if (operator === "eq") { return eb(field, "=", value); } if (operator === "ne") { return eb(field, "<>", value); } if (operator === "gt") { return eb(field, ">", value); } if (operator === "gte") { return eb(field, ">=", value); } if (operator === "lt") { return eb(field, "<", value); } if (operator === "lte") { return eb(field, "<=", value); } return eb(field, operator, value); }; if (connector === "OR") { conditions.or.push(expr); } else { conditions.and.push(expr); } }); return { and: conditions.and.length ? conditions.and : null, or: conditions.or.length ? conditions.or : null, }; } return { async create({ data, model }) { const builder = db.insertInto(model).values(data); const returned = await withReturning(data, builder, model, []); return returned; }, async findOne({ model, where, select }) { const { and, or } = convertWhereClause(model, where); let query = db.selectFrom(model).selectAll(); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } const res = await query.executeTakeFirst(); if (!res) return null; return res as any; }, async findMany({ model, where, limit, offset, sortBy }) { const { and, or } = convertWhereClause(model, where); let query = db.selectFrom(model); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } if (config?.type === "mssql") { if (!offset) { query = query.top(limit || 100); } } else { query = query.limit(limit || 100); } if (sortBy) { query = query.orderBy( getFieldName({ model, field: sortBy.field }), sortBy.direction, ); } if (offset) { if (config?.type === "mssql") { if (!sortBy) { query = query.orderBy(getFieldName({ model, field: "id" })); } query = query.offset(offset).fetch(limit || 100); } else { query = query.offset(offset); } } const res = await query.selectAll().execute(); if (!res) return []; return res as any; }, async update({ model, where, update: values }) { const { and, or } = convertWhereClause(model, where); let query = db.updateTable(model).set(values as any); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } return await withReturning(values as any, query, model, where); }, async updateMany({ model, where, update: values }) { const { and, or } = convertWhereClause(model, where); let query = db.updateTable(model).set(values as any); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } const res = await query.execute(); return res.length; }, async count({ model, where }) { const { and, or } = convertWhereClause(model, where); let query = db .selectFrom(model) // a temporal solution for counting other than "*" - see more - https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted .select(db.fn.count("id").as("count")); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } const res = await query.execute(); if (typeof res[0]!.count === "number") { return res[0]!.count; } if (typeof res[0]!.count === "bigint") { return Number(res[0]!.count); } return parseInt(res[0]!.count); }, async delete({ model, where }) { const { and, or } = convertWhereClause(model, where); let query = db.deleteFrom(model); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } await query.execute(); }, async deleteMany({ model, where }) { const { and, or } = convertWhereClause(model, where); let query = db.deleteFrom(model); if (and) { query = query.where((eb) => eb.and(and.map((expr) => expr(eb)))); } if (or) { query = query.where((eb) => eb.or(or.map((expr) => expr(eb)))); } return (await query.execute()).length; }, options: config, }; }; }; let adapterOptions: AdapterFactoryOptions | null = null; adapterOptions = { config: { adapterId: "kysely", adapterName: "Kysely Adapter", usePlural: config?.usePlural, debugLogs: config?.debugLogs, supportsBooleans: config?.type === "sqlite" || config?.type === "mssql" || config?.type === "mysql" || !config?.type ? false : true, supportsDates: config?.type === "sqlite" || config?.type === "mssql" || !config?.type ? false : true, supportsJSON: false, transaction: (config?.transaction ?? false) ? (cb) => db.transaction().execute((trx) => { const adapter = createAdapterFactory({ config: adapterOptions!.config, adapter: createCustomAdapter(trx), })(lazyOptions!); return cb(adapter); }) : false, }, adapter: createCustomAdapter(db), }; const adapter = createAdapterFactory(adapterOptions); return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => { lazyOptions = options; return adapter(options); }; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/captcha/captcha.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { captcha } from "."; import * as betterFetchModule from "@better-fetch/fetch"; vi.mock("@better-fetch/fetch", async (importOriginal) => { const actual = (await importOriginal()) as typeof betterFetchModule; return { ...actual, betterFetch: vi.fn(), }; }); describe("captcha", async (it) => { const mockBetterFetch = betterFetchModule.betterFetch as ReturnType< typeof vi.fn >; it("Should ignore non-protected endpoints", async () => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "cloudflare-turnstile", secretKey: "xx-secret-key", endpoints: ["/sign-up"], }), ], }); mockBetterFetch.mockResolvedValue({ data: { success: false, "error-codes": ["invalid-input-response"], }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "invalid-captcha-token", }, }, }); expect(res.data?.user).toBeDefined(); }); it("Should return a 500 when missing secret key", async () => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "cloudflare-turnstile", secretKey: "", }), ], }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "invalid-captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); it("Should return 400 if no captcha token is found in the request headers", async () => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "cloudflare-turnstile", secretKey: "xx-secret-key", }), ], }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: {}, }, }); expect(res.error?.status).toBe(400); }); it("Should return 500 if an unexpected error occurs", async () => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "cloudflare-turnstile", secretKey: "xx-secret-key", }), ], }); mockBetterFetch.mockRejectedValue(new Error("Failed to fetch")); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); describe("cloudflare-turnstile", async (it) => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "cloudflare-turnstile", secretKey: "xx-secret-key", }), ], }); const headers = new Headers(); it("Should successfully sign in users if they passed the CAPTCHA challenge", async () => { mockBetterFetch.mockResolvedValue({ data: { success: true, challenge_ts: "2022-02-28T15:14:30.096Z", hostname: "example.com", "error-codes": [], action: "login", cdata: "sessionid-123456789", metadata: { ephemeral_id: "x:9f78e0ed210960d7693b167e", }, }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.data?.user).toBeDefined(); }); it("Should return 500 if the call to /siteverify fails", async () => { mockBetterFetch.mockResolvedValue({ error: "Failed to fetch", }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); it("Should return 403 in case of a validation failure", async () => { mockBetterFetch.mockResolvedValue({ data: { success: false, "error-codes": ["invalid-input-response"], }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(403); }); }); describe("google-recaptcha", async (it) => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "google-recaptcha", secretKey: "xx-secret-key" }), ], }); const headers = new Headers(); it("Should successfully sign in users if they passed the CAPTCHA challenge", async () => { mockBetterFetch.mockResolvedValue({ data: { success: true, challenge_ts: "2022-02-28T15:14:30.096Z", hostname: "example.com", }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.data?.user).toBeDefined(); }); it("Should return 500 if the call to /siteverify fails", async () => { mockBetterFetch.mockResolvedValue({ error: "Failed to fetch", }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); it("Should return 403 in case of a validation failure", async () => { mockBetterFetch.mockResolvedValue({ data: { success: false, "error-codes": ["invalid-input-response"], }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "invalid-captcha-token", }, }, }); expect(res.error?.status).toBe(403); }); it("Should return 403 in case of a too low score (ReCAPTCHA v3)", async () => { mockBetterFetch.mockResolvedValue({ data: { success: true, score: 0.4, // Default minScore is 0.5 action: "yourAction", challenge_ts: "2022-02-28T15:14:30.096Z", hostname: "example.com", }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "low-score-captcha-token", }, }, }); expect(res.error?.status).toBe(403); }); // TODO: Adding tests for hCaptcha }); describe("hcaptcha", async (it) => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "hcaptcha", secretKey: "xx-secret-key", siteKey: "xx-site-key", }), ], }); const headers = new Headers(); it("Should successfully sign in users if they passed the CAPTCHA challenge", async () => { mockBetterFetch.mockResolvedValue({ data: { success: true, challenge_ts: "2022-02-28T15:14:30.096Z", hostname: "example.com", }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.data?.user).toBeDefined(); }); it("Should return 500 if the call to /siteverify fails", async () => { mockBetterFetch.mockResolvedValue({ error: "Failed to fetch", }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); it("Should return 403 in case of a validation failure", async () => { mockBetterFetch.mockResolvedValue({ data: { success: false, "error-codes": ["invalid-input-response"], }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "invalid-captcha-token", }, }, }); expect(res.error?.status).toBe(403); }); // TODO: Adding tests for hCaptcha }); describe("captchafox", async (it) => { const { client } = await getTestInstance({ plugins: [ captcha({ provider: "captchafox", secretKey: "xx-secret-key", siteKey: "xx-site-key", }), ], }); it("Should successfully sign in users if they passed the CAPTCHA challenge", async () => { mockBetterFetch.mockResolvedValue({ data: { success: true, challenge_ts: "2022-02-28T15:14:30.096Z", hostname: "example.com", }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.data?.user).toBeDefined(); }); it("Should return 500 if the call to /siteverify fails", async () => { mockBetterFetch.mockResolvedValue({ error: "Failed to fetch", }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "captcha-token", }, }, }); expect(res.error?.status).toBe(500); }); it("Should return 403 in case of a validation failure", async () => { mockBetterFetch.mockResolvedValue({ data: { success: false, "error-codes": ["invalid-input-response"], }, }); const res = await client.signIn.email({ email: "[email protected]", password: "test123456", fetchOptions: { headers: { "x-captcha-response": "invalid-captcha-token", }, }, }); expect(res.error?.status).toBe(403); }); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/db/get-migration.ts: -------------------------------------------------------------------------------- ```typescript import type { AlterTableColumnAlteringBuilder, CreateTableBuilder, } from "kysely"; import type { DBFieldAttribute, DBFieldType } from "@better-auth/core/db"; import { sql } from "kysely"; import { createLogger } from "@better-auth/core/env"; import type { BetterAuthOptions } from "@better-auth/core"; import { createKyselyAdapter } from "../adapters/kysely-adapter/dialect"; import type { KyselyDatabaseType } from "../adapters/kysely-adapter/types"; import { getSchema } from "./get-schema"; const postgresMap = { string: ["character varying", "varchar", "text"], number: [ "int4", "integer", "bigint", "smallint", "numeric", "real", "double precision", ], boolean: ["bool", "boolean"], date: ["timestamptz", "timestamp", "date"], json: ["json", "jsonb"], }; const mysqlMap = { string: ["varchar", "text"], number: [ "integer", "int", "bigint", "smallint", "decimal", "float", "double", ], boolean: ["boolean", "tinyint"], date: ["timestamp", "datetime", "date"], json: ["json"], }; const sqliteMap = { string: ["TEXT"], number: ["INTEGER", "REAL"], boolean: ["INTEGER", "BOOLEAN"], // 0 or 1 date: ["DATE", "INTEGER"], json: ["TEXT"], }; const mssqlMap = { string: ["varchar", "nvarchar"], number: ["int", "bigint", "smallint", "decimal", "float", "double"], boolean: ["bit", "smallint"], date: ["datetime2", "date", "datetime"], json: ["varchar", "nvarchar"], }; const map = { postgres: postgresMap, mysql: mysqlMap, sqlite: sqliteMap, mssql: mssqlMap, }; export function matchType( columnDataType: string, fieldType: DBFieldType, dbType: KyselyDatabaseType, ) { function normalize(type: string) { return type.toLowerCase().split("(")[0]!.trim(); } if (fieldType === "string[]" || fieldType === "number[]") { return columnDataType.toLowerCase().includes("json"); } const types = map[dbType]!; const expected = Array.isArray(fieldType) ? types["string"].map((t) => t.toLowerCase()) : types[fieldType]!.map((t) => t.toLowerCase()); return expected.includes(normalize(columnDataType)); } export async function getMigrations(config: BetterAuthOptions) { const betterAuthSchema = getSchema(config); const logger = createLogger(config.logger); let { kysely: db, databaseType: dbType } = await createKyselyAdapter(config); if (!dbType) { logger.warn( "Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.", ); dbType = "sqlite"; } if (!db) { logger.error( "Only kysely adapter is supported for migrations. You can use `generate` command to generate the schema, if you're using a different adapter.", ); process.exit(1); } const tableMetadata = await db.introspection.getTables(); const toBeCreated: { table: string; fields: Record<string, DBFieldAttribute>; order: number; }[] = []; const toBeAdded: { table: string; fields: Record<string, DBFieldAttribute>; order: number; }[] = []; for (const [key, value] of Object.entries(betterAuthSchema)) { const table = tableMetadata.find((t) => t.name === key); if (!table) { const tIndex = toBeCreated.findIndex((t) => t.table === key); const tableData = { table: key, fields: value.fields, order: value.order || Infinity, }; const insertIndex = toBeCreated.findIndex( (t) => (t.order || Infinity) > tableData.order, ); if (insertIndex === -1) { if (tIndex === -1) { toBeCreated.push(tableData); } else { toBeCreated[tIndex]!.fields = { ...toBeCreated[tIndex]!.fields, ...value.fields, }; } } else { toBeCreated.splice(insertIndex, 0, tableData); } continue; } let toBeAddedFields: Record<string, DBFieldAttribute> = {}; for (const [fieldName, field] of Object.entries(value.fields)) { const column = table.columns.find((c) => c.name === fieldName); if (!column) { toBeAddedFields[fieldName] = field; continue; } if (matchType(column.dataType, field.type, dbType)) { continue; } else { logger.warn( `Field ${fieldName} in table ${key} has a different type in the database. Expected ${field.type} but got ${column.dataType}.`, ); } } if (Object.keys(toBeAddedFields).length > 0) { toBeAdded.push({ table: key, fields: toBeAddedFields, order: value.order || Infinity, }); } } const migrations: ( | AlterTableColumnAlteringBuilder | CreateTableBuilder<string, string> )[] = []; function getType(field: DBFieldAttribute, fieldName: string) { const type = field.type; const typeMap = { string: { sqlite: "text", postgres: "text", mysql: field.unique ? "varchar(255)" : field.references ? "varchar(36)" : "text", mssql: field.unique || field.sortable ? "varchar(255)" : field.references ? "varchar(36)" : // mssql deprecated `text`, and the alternative is `varchar(max)`. // Kysely type interface doesn't support `text`, so we set this to `varchar(8000)` as // that's the max length for `varchar` "varchar(8000)", }, boolean: { sqlite: "integer", postgres: "boolean", mysql: "boolean", mssql: "smallint", }, number: { sqlite: field.bigint ? "bigint" : "integer", postgres: field.bigint ? "bigint" : "integer", mysql: field.bigint ? "bigint" : "integer", mssql: field.bigint ? "bigint" : "integer", }, date: { sqlite: "date", postgres: "timestamptz", mysql: "timestamp(3)", mssql: sql`datetime2(3)`, }, json: { sqlite: "text", postgres: "jsonb", mysql: "json", mssql: "varchar(8000)", }, id: { postgres: config.advanced?.database?.useNumberId ? "serial" : "text", mysql: config.advanced?.database?.useNumberId ? "integer" : "varchar(36)", mssql: config.advanced?.database?.useNumberId ? "integer" : "varchar(36)", sqlite: config.advanced?.database?.useNumberId ? "integer" : "text", }, foreignKeyId: { postgres: config.advanced?.database?.useNumberId ? "integer" : "text", mysql: config.advanced?.database?.useNumberId ? "integer" : "varchar(36)", mssql: config.advanced?.database?.useNumberId ? "integer" : "varchar(36)", sqlite: config.advanced?.database?.useNumberId ? "integer" : "text", }, } as const; if (fieldName === "id" || field.references?.field === "id") { if (fieldName === "id") { return typeMap.id[dbType!]; } return typeMap.foreignKeyId[dbType!]; } if (dbType === "sqlite" && (type === "string[]" || type === "number[]")) { return "text"; } if (type === "string[]" || type === "number[]") { return "jsonb"; } if (Array.isArray(type)) { return "text"; } return typeMap[type]![dbType || "sqlite"]; } if (toBeAdded.length) { for (const table of toBeAdded) { for (const [fieldName, field] of Object.entries(table.fields)) { const type = getType(field, fieldName); const exec = db.schema .alterTable(table.table) .addColumn(fieldName, type, (col) => { col = field.required !== false ? col.notNull() : col; if (field.references) { col = col .references( `${field.references.model}.${field.references.field}`, ) .onDelete(field.references.onDelete || "cascade"); } if (field.unique) { col = col.unique(); } if ( field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql") ) { if (dbType === "mysql") { col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`); } else { col = col.defaultTo(sql`CURRENT_TIMESTAMP`); } } return col; }); migrations.push(exec); } } } if (toBeCreated.length) { for (const table of toBeCreated) { let dbT = db.schema .createTable(table.table) .addColumn( "id", config.advanced?.database?.useNumberId ? dbType === "postgres" ? "serial" : "integer" : dbType === "mysql" || dbType === "mssql" ? "varchar(36)" : "text", (col) => { if (config.advanced?.database?.useNumberId) { if (dbType === "postgres" || dbType === "sqlite") { return col.primaryKey().notNull(); } else if (dbType === "mssql") { return col.identity().primaryKey().notNull(); } return col.autoIncrement().primaryKey().notNull(); } return col.primaryKey().notNull(); }, ); const indices: Array<{ table: string; field: string }> = []; for (const [fieldName, field] of Object.entries(table.fields)) { const type = getType(field, fieldName); dbT = dbT.addColumn(fieldName, type, (col) => { col = field.required !== false ? col.notNull() : col; if (field.references) { col = col .references(`${field.references.model}.${field.references.field}`) .onDelete(field.references.onDelete || "cascade"); } if (field.unique) { col = col.unique(); } if ( field.type === "date" && typeof field.defaultValue === "function" && (dbType === "postgres" || dbType === "mysql" || dbType === "mssql") ) { if (dbType === "mysql") { col = col.defaultTo(sql`CURRENT_TIMESTAMP(3)`); } else { col = col.defaultTo(sql`CURRENT_TIMESTAMP`); } } return col; }); } migrations.push(dbT); } } async function runMigrations() { for (const migration of migrations) { await migration.execute(); } } async function compileMigrations() { const compiled = migrations.map((m) => m.compile().sql); return compiled.join(";\n\n") + ";"; } return { toBeCreated, toBeAdded, runMigrations, compileMigrations }; } ``` -------------------------------------------------------------------------------- /docs/content/blogs/0-supabase-auth-to-planetscale-migration.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Migrate from Supabase Auth to Better Auth + PlanetScale PostgreSQL description: This migration guide aims to guide you move your auth from Supabase Auth to Better Auth on PlanetScale PostgreSQL. date: 2025-08-25 author: name: "Dagmawi Esayas" avatar: "" image: "/blogs/supabase-ps.png" tags: ["migration", "guides", "supabase", "planetscale"] --- import { HomeIcon } from "lucide-react"; import { Step, Steps } from "fumadocs-ui/components/steps"; # Supabase Auth to Better Auth + PlanetScale PostgreSQL Migration Guide Recently, [PlanetScale announced](https://planetscale.com/blog/planetscale-for-postgres) support for PostgreSQL. This is exciting news for developers and a big step forward for the database industry. We’ve noticed that some users are migrating from Supabase to PlanetScale PostgreSQL, but facing challenges because they also rely on Supabase Auth. This guide will help you migrate your authentication from Supabase Auth to Better Auth on PlanetScale PostgreSQL. ## 1. Setup a PlanetScale Database <Steps> <Step> Open the PlanetScale [dashboard](https://app.planetscale.com/) </Step> <Step> Create a [new database](<[https://app.planetscale.com/new](https://app.planetscale.com/new?org=better-auth)>) </Step> <Step> Get your connection string (PostgreSQL URI) ```jsx postgresql://<username>:<password>@<host>/postgres?sslmode=verify-full ``` </Step> <Step> Save the database URL in your `.env` file for later use with Better Auth: ```txt title=".env" DATABASE_URL = postgresql://<username>:<password>@<host>/postgres?sslmode=verify-full ``` </Step> </Steps> <Callout> This is what will be in the `database` field of our auth config </Callout> ## 2. Install Better Auth <Steps> <Step> Install Better Auth ```package-install npm install better-auth ``` </Step> <Step> Follow and complete the basic setup [here](https://www.better-auth.com/docs/installation) </Step> </Steps> <Callout> Make sure to set up all required environment variables as per the docs. </Callout> ## 3. Install PostgreSQL Client Install the `pg` package and its types: ```package-install npm install pg npm install --save-dev @types/pg ``` ## 4. Generate & Migrate Better Auth Schema <Steps> <Step> Run this cli command to generate all the schema needed to setup Better Auth: ```bash npx @better-auth/cli generate ``` </Step> <Step> Then run this command to apply the generated schema to your PlanetScale database: ```bash npx @better-auth/cli migrate ``` </Step> </Steps> <Callout type="success"> You should now have the required auth tables in PlanetScale. </Callout> ### 5. Quick Check Your auth config should be like this: <Tabs items={["auth.ts", "auth-client.ts"]}> <Tab value="auth.ts"> ```ts import { Pool } from "pg"; import { betterAuth } from "better-auth"; export const auth = betterAuth({ baseURL: "http://localhost:3000", database: new Pool({ connectionString: process.env.DATABASE_URL, }), emailAndPassword: { enabled: true, }, }); ``` </Tab> <Tab value="auth-client.ts"> ```ts import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: "http://localhost:3000", }); export const { signIn, signUp, useSession } = createAuthClient(); ``` </Tab> </Tabs> ### 6. The Fun Part Now comes the fun part. You are now all setup to move your auth from Supabase Auth to Better Auth and all you have to do is go through the instances you've used Supabase Auth client and replace it with Better Auth client. We are going to see a few examples here. <Tabs items={["sign up", "sign in", "session"]}> <Tab value="sign up"> ```ts // Supabase Auth await supabase.auth.signUp({ email, password, }); // Better Auth await authClient.signUp.email({ email, password, name: "John", }); ``` </Tab> <Tab value="sign in"> ```ts // Supabase await supabase.auth.signInWithPassword({ email, password, }); // Better Auth await authClient.signIn.email({ email, password, }); ``` </Tab> <Tab value="session"> ```ts // Supabase const { data, error } = await supabase.auth.getClaims(); // Better Auth const { data, error } = await authClient.useSession(); ``` </Tab> </Tabs> ### 7. Migrate your users from Supabase Auth <Callout type="warn"> This migration will invalidate all active sessions. While this guide doesn't currently cover migrating two-factor (2FA) or Row Level Security (RLS) configurations, both should be possible with additional steps. </Callout> <Callout type="info"> For a more detailed guide checkout [this guide](https://www.better-auth.com/docs/guides/supabase-migration-guide) we made. </Callout> Essentially you should be able to copy the following code into `migration.ts` and run it. ```ts title="migration.ts" import { Pool } from "pg"; import { auth } from "./lib/auth"; import { User as SupabaseUser } from "@supabase/supabase-js"; type User = SupabaseUser & { is_super_admin: boolean; raw_user_meta_data: { avatar_url: string; }; encrypted_password: string; email_confirmed_at: string; created_at: string; updated_at: string; is_anonymous: boolean; identities: { provider: string; identity_data: { sub: string; email: string; }; created_at: string; updated_at: string; }; }; const migrateFromSupabase = async () => { const ctx = await auth.$context; const db = ctx.options.database as Pool; const users = await db .query( ` SELECT u.*, COALESCE( json_agg( i.* ORDER BY i.id ) FILTER (WHERE i.id IS NOT NULL), '[]'::json ) as identities FROM auth.users u LEFT JOIN auth.identities i ON u.id = i.user_id GROUP BY u.id ` ) .then((res) => res.rows as User[]); for (const user of users) { if (!user.email) { continue; } await ctx.adapter .create({ model: "user", data: { id: user.id, email: user.email, name: user.email, role: user.is_super_admin ? "admin" : user.role, emailVerified: !!user.email_confirmed_at, image: user.raw_user_meta_data.avatar_url, createdAt: new Date(user.created_at), updatedAt: new Date(user.updated_at), isAnonymous: user.is_anonymous, }, }) .catch(() => {}); for (const identity of user.identities) { const existingAccounts = await ctx.internalAdapter.findAccounts(user.id); if (identity.provider === "email") { const hasCredential = existingAccounts.find( (account: { providerId: string }) => account.providerId === "credential" ); if (!hasCredential) { await ctx.adapter .create({ model: "account", data: { userId: user.id, providerId: "credential", accountId: user.id, password: user.encrypted_password, createdAt: new Date(user.created_at), updatedAt: new Date(user.updated_at), }, }) .catch(() => {}); } } const supportedProviders = Object.keys(ctx.options.socialProviders || {}); if (supportedProviders.includes(identity.provider)) { const hasAccount = existingAccounts.find( (account: { providerId: string }) => account.providerId === identity.provider ); if (!hasAccount) { await ctx.adapter.create({ model: "account", data: { userId: user.id, providerId: identity.provider, accountId: identity.identity_data?.sub, createdAt: new Date(identity.created_at ?? user.created_at), updatedAt: new Date(identity.updated_at ?? user.updated_at), }, }); } } } } }; migrateFromSupabase(); ``` Run the migration script ```bash title="Terminal" bun migration.ts # or use node, ts-node, etc. ``` ### 8. Migrate the Rest of Your Data If you have additional user-related data in Supabase, you can use the [Supabase to PlanetScale migration tool](https://planetscale.com/docs/postgres/imports/supabase). ### 9. Clean up all the Supabase Auth code from your codebase You now own your auth, you should start removing all the Supabase Auth related code. ### 10. Done! 🎉 You've successfully migrated from Supabase Auth to Better Auth on PlanetScale. ### Tips - Double-check that all environment variables are set in production. - Test all auth flows (sign-up, login, password reset, session refresh) before going live. - Remember that this is just the basics and if you've integrated Supabase Auth's auth functions in a lot of placed you'd have to find the suitable Better Auth replacements [here](https://www.better-auth.com/docs). - Have fun! ### Learn More! <Cards> <Card href="https://www.better-auth.com/docs/introduction" title="Better Auth Setup" > Get started with installing Better Auth </Card> <Card href="https://planetscale.com/docs/vitess/tutorials/planetscale-quick-start-guide" title="PlanetScale Quick Start" > Get started on PlanetScale here </Card> <Card href="https://planetscale.com/docs/postgres/imports/postgres-imports" title="PlanetScale Migration Guides" > Use this guide to move your data from Supabase and many more services </Card> <Card href="https://www.better-auth.com/docs/guides/supabase-migration-guide" title="Supabase Auth Migration" > Move your auth from Supabase Auth to your own DB </Card> </Cards> ``` -------------------------------------------------------------------------------- /packages/expo/src/expo.test.ts: -------------------------------------------------------------------------------- ```typescript import { createAuthClient } from "better-auth/react"; import Database from "better-sqlite3"; import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; import { expo } from "."; import { expoClient } from "./client"; import { betterAuth } from "better-auth"; import { getMigrations } from "better-auth/db"; import { oAuthProxy } from "better-auth/plugins"; vi.mock("expo-web-browser", async () => { return { openAuthSessionAsync: vi.fn(async (...args) => { fn(...args); return { type: "success", url: "better-auth://?cookie=better-auth.session_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYxMzQwZj", }; }), }; }); vi.mock("react-native", async () => { return { Platform: { OS: "android", }, }; }); vi.mock("expo-constants", async () => { return { default: { platform: { scheme: "better-auth", }, }, }; }); vi.mock("expo-linking", async () => { return { createURL: vi.fn((url) => `better-auth://${url}`), }; }); const fn = vi.fn(); function testUtils(extraOpts?: Parameters<typeof betterAuth>[0]) { const storage = new Map<string, string>(); const auth = betterAuth({ baseURL: "http://localhost:3000", database: new Database(":memory:"), emailAndPassword: { enabled: true, }, socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, plugins: [expo(), oAuthProxy()], trustedOrigins: ["better-auth://"], ...extraOpts, }); const client = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }, plugins: [ expoClient({ storage: { getItem: (key) => storage.get(key) || null, setItem: async (key, value) => storage.set(key, value), }, }), ], }); return { storage, auth, client }; } describe("expo", async () => { const { auth, client, storage } = testUtils(); beforeAll(async () => { const { runMigrations } = await getMigrations(auth.options); await runMigrations(); vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); }); it("should store cookie with expires date", async () => { const testUser = { email: "[email protected]", password: "password", name: "Test User", }; await client.signUp.email(testUser); const storedCookie = storage.get("better-auth_cookie"); expect(storedCookie).toBeDefined(); const parsedCookie = JSON.parse(storedCookie || ""); expect(parsedCookie["better-auth.session_token"]).toMatchObject({ value: expect.stringMatching(/.+/), expires: expect.any(String), }); }); it("should send cookie and get session", async () => { const { data } = await client.getSession(); expect(data).toMatchObject({ session: expect.any(Object), user: expect.any(Object), }); }); it("should use the scheme to open the browser", async () => { const { data: res } = await client.signIn.social({ provider: "google", callbackURL: "/dashboard", }); const stateId = res?.url?.split("state=")[1]!.split("&")[0]; const ctx = await auth.$context; if (!stateId) { throw new Error("State ID not found"); } const state = await ctx.internalAdapter.findVerificationValue(stateId); const callbackURL = JSON.parse(state?.value || "{}").callbackURL; expect(callbackURL).toBe("better-auth:///dashboard"); expect(res).toMatchObject({ url: expect.stringContaining("accounts.google"), }); expect(fn).toHaveBeenCalledWith( expect.stringContaining("accounts.google"), "better-auth:///dashboard", ); }); it("should get cookies", async () => { const c = client.getCookie(); expect(c).includes("better-auth.session_token"); }); it("should correctly parse multiple Set-Cookie headers with Expires commas", async () => { const header = "better-auth.session_token=abc; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/, better-auth.session_data=xyz; Expires=Thu, 22 Oct 2015 07:28:00 GMT; Path=/"; const map = (await import("./client")).parseSetCookieHeader(header); expect(map.get("better-auth.session_token")?.value).toBe("abc"); expect(map.get("better-auth.session_data")?.value).toBe("xyz"); }); it("should not trigger infinite refetch with non-better-auth cookies", async () => { const { hasBetterAuthCookies } = await import("./client"); const betterAuthOnlyHeader = "better-auth.session_token=abc; Path=/"; expect(hasBetterAuthCookies(betterAuthOnlyHeader, "better-auth")).toBe( true, ); const sessionDataHeader = "better-auth.session_data=xyz; Path=/"; expect(hasBetterAuthCookies(sessionDataHeader, "better-auth")).toBe(true); const secureBetterAuthHeader = "__Secure-better-auth.session_token=abc; Path=/"; expect(hasBetterAuthCookies(secureBetterAuthHeader, "better-auth")).toBe( true, ); const secureSessionDataHeader = "__Secure-better-auth.session_data=xyz; Path=/"; expect(hasBetterAuthCookies(secureSessionDataHeader, "better-auth")).toBe( true, ); const nonBetterAuthHeader = "__cf_bm=abc123; Path=/; HttpOnly; Secure"; expect(hasBetterAuthCookies(nonBetterAuthHeader, "better-auth")).toBe( false, ); const mixedHeader = "__cf_bm=abc123; Path=/; HttpOnly; Secure, better-auth.session_token=xyz; Path=/"; expect(hasBetterAuthCookies(mixedHeader, "better-auth")).toBe(true); const customPrefixHeader = "my-app.session_token=abc; Path=/"; expect(hasBetterAuthCookies(customPrefixHeader, "my-app")).toBe(true); expect(hasBetterAuthCookies(customPrefixHeader, "better-auth")).toBe(false); const customPrefixDataHeader = "my-app.session_data=abc; Path=/"; expect(hasBetterAuthCookies(customPrefixDataHeader, "my-app")).toBe(true); const emptyPrefixHeader = "session_token=abc; Path=/"; expect(hasBetterAuthCookies(emptyPrefixHeader, "")).toBe(true); const customFullNameHeader = "my_custom_session_token=abc; Path=/"; expect(hasBetterAuthCookies(customFullNameHeader, "")).toBe(true); const customFullDataHeader = "my_custom_session_data=xyz; Path=/"; expect(hasBetterAuthCookies(customFullDataHeader, "")).toBe(true); const multipleNonBetterAuthHeader = "__cf_bm=abc123; Path=/, _ga=GA1.2.123456789.1234567890; Path=/"; expect( hasBetterAuthCookies(multipleNonBetterAuthHeader, "better-auth"), ).toBe(false); const nonSessionBetterAuthHeader = "better-auth.other_cookie=abc; Path=/"; expect( hasBetterAuthCookies(nonSessionBetterAuthHeader, "better-auth"), ).toBe(false); }); it("should preserve unchanged client store session properties on signout", async () => { const before = client.$store.atoms.session!.get(); await client.signOut(); const after = client.$store.atoms.session!.get(); expect(after).toMatchObject({ ...before, data: null, error: null, isPending: false, }); }); }); describe("expo with cookieCache", async () => { const { auth, client, storage } = testUtils({ session: { expiresIn: 5, cookieCache: { enabled: true, maxAge: 1, }, }, }); beforeAll(async () => { const { runMigrations } = await getMigrations(auth.options); await runMigrations(); vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); }); it("should store cookie with expires date", async () => { const testUser = { email: "[email protected]", password: "password", name: "Test User", }; await client.signUp.email(testUser); const storedCookie = storage.get("better-auth_cookie"); expect(storedCookie).toBeDefined(); const parsedCookie = JSON.parse(storedCookie || ""); expect(parsedCookie["better-auth.session_token"]).toMatchObject({ value: expect.stringMatching(/.+/), expires: expect.any(String), }); expect(parsedCookie["better-auth.session_data"]).toMatchObject({ value: expect.stringMatching(/.+/), expires: expect.any(String), }); }); it("should refresh session_data when it expired without erasing session_token", async () => { vi.advanceTimersByTime(1000); const { data } = await client.getSession(); expect(data).toMatchObject({ session: expect.any(Object), user: expect.any(Object), }); const storedCookie = storage.get("better-auth_cookie"); expect(storedCookie).toBeDefined(); const parsedCookie = JSON.parse(storedCookie || ""); expect(parsedCookie["better-auth.session_token"]).toMatchObject({ value: expect.any(String), expires: expect.any(String), }); expect(parsedCookie["better-auth.session_data"]).toMatchObject({ value: expect.any(String), expires: expect.any(String), }); }); it("should erase both session_data and session_token when token expired", async () => { vi.advanceTimersByTime(5000); const { data } = await client.getSession(); expect(data).toBeNull(); const storedCookie = storage.get("better-auth_cookie"); expect(storedCookie).toBeDefined(); const parsedCookie = JSON.parse(storedCookie || ""); expect(parsedCookie["better-auth.session_token"]).toMatchObject({ value: expect.any(String), expires: expect.any(String), }); expect(parsedCookie["better-auth.session_data"]).toMatchObject({ value: expect.any(String), expires: expect.any(String), }); }); it("should add `exp://` to trusted origins", async () => { vi.stubEnv("NODE_ENV", "development"); const auth = betterAuth({ plugins: [expo()], trustedOrigins: ["http://localhost:3000"], }); const ctx = await auth.$context; expect(ctx.options.trustedOrigins).toContain("exp://"); expect(ctx.options.trustedOrigins).toContain("http://localhost:3000"); }); it("should allow independent cookiePrefix configuration", async () => { const { hasBetterAuthCookies } = await import("./client"); const customCookieHeader = "my-app.session_token=abc; Path=/"; expect(hasBetterAuthCookies(customCookieHeader, "my-app")).toBe(true); expect(hasBetterAuthCookies(customCookieHeader, "better-auth")).toBe(false); }); }); ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/phone-number.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Phone Number description: Phone number plugin --- The phone number plugin extends the authentication system by allowing users to sign in and sign up using their phone number. It includes OTP (One-Time Password) functionality to verify phone numbers. ## Installation <Steps> <Step> ### Add Plugin to the server ```ts title="auth.ts" import { betterAuth } from "better-auth" import { phoneNumber } from "better-auth/plugins" const auth = betterAuth({ plugins: [ phoneNumber({ // [!code highlight] sendOTP: ({ phoneNumber, code }, request) => { // [!code highlight] // Implement sending OTP code via SMS // [!code highlight] } // [!code highlight] }) // [!code highlight] ] }) ``` </Step> <Step> ### Migrate the database Run the migration or generate the schema to add the necessary fields and tables to the database. <Tabs items={["migrate", "generate"]}> <Tab value="migrate"> ```bash npx @better-auth/cli migrate ``` </Tab> <Tab value="generate"> ```bash npx @better-auth/cli generate ``` </Tab> </Tabs> See the [Schema](#schema) section to add the fields manually. </Step> <Step> ### Add the client plugin ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" import { phoneNumberClient } from "better-auth/client/plugins" const authClient = createAuthClient({ plugins: [ // [!code highlight] phoneNumberClient() // [!code highlight] ] // [!code highlight] }) ``` </Step> </Steps> ## Usage ### Send OTP for Verification To send an OTP to a user's phone number for verification, you can use the `sendVerificationCode` endpoint. <APIMethod path="/phone-number/send-otp" method="POST"> ```ts type sendPhoneNumberOTP = { /** * Phone number to send OTP. */ phoneNumber: string = "+1234567890" } ``` </APIMethod> ### Verify Phone Number After the OTP is sent, users can verify their phone number by providing the code. <APIMethod path="/phone-number/verify" method="POST"> ```ts type verifyPhoneNumber = { /** * Phone number to verify. */ phoneNumber: string = "+1234567890" /** * OTP code. */ code: string = "123456" /** * Disable session creation after verification. */ disableSession?: boolean = false /** * Check if there is a session and update the phone number. */ updatePhoneNumber?: boolean = true } ``` </APIMethod> <Callout> When the phone number is verified, the `phoneNumberVerified` field in the user table is set to `true`. If `disableSession` is not set to `true`, a session is created for the user. Additionally, if `callbackOnVerification` is provided, it will be called. </Callout> ### Allow Sign-Up with Phone Number To allow users to sign up using their phone number, you can pass `signUpOnVerification` option to your plugin configuration. It requires you to pass `getTempEmail` function to generate a temporary email for the user. ```ts title="auth.ts" export const auth = betterAuth({ plugins: [ phoneNumber({ sendOTP: ({ phoneNumber, code }, request) => { // Implement sending OTP code via SMS }, signUpOnVerification: { getTempEmail: (phoneNumber) => { return `${phoneNumber}@my-site.com` }, //optionally, you can also pass `getTempName` function to generate a temporary name for the user getTempName: (phoneNumber) => { return phoneNumber //by default, it will use the phone number as the name } } }) ] }) ``` ### Sign In with Phone Number In addition to signing in a user using send-verify flow, you can also use phone number as an identifier and sign in a user using phone number and password. <APIMethod path="/sign-in/phone-number" method="POST"> ```ts type signInPhoneNumber = { /** * Phone number to sign in. */ phoneNumber: string = "+1234567890" /** * Password to use for sign in. */ password: string /** * Remember the session. */ rememberMe?: boolean = true } ``` </APIMethod> ### Update Phone Number Updating phone number uses the same process as verifying a phone number. The user will receive an OTP code to verify the new phone number. ```ts title="auth-client.ts" await authClient.phoneNumber.sendOtp({ phoneNumber: "+1234567890" // New phone number }) ``` Then verify the new phone number with the OTP code. ```ts title="auth-client.ts" const isVerified = await authClient.phoneNumber.verify({ phoneNumber: "+1234567890", code: "123456", updatePhoneNumber: true // Set to true to update the phone number [!code highlight] }) ``` If a user session exist the phone number will be updated automatically. ### Disable Session Creation By default, the plugin creates a session for the user after verifying the phone number. You can disable this behavior by passing `disableSession: true` to the `verify` method. ```ts title="auth-client.ts" const isVerified = await authClient.phoneNumber.verify({ phoneNumber: "+1234567890", code: "123456", disableSession: true // [!code highlight] }) ``` ### Request Password Reset To initiate a request password reset flow using `phoneNumber`, you can start by calling `requestPasswordReset` on the client to send an OTP code to the user's phone number. <APIMethod path="/phone-number/request-password-reset" method="POST"> ```ts type requestPasswordResetPhoneNumber = { /** * The phone number which is associated with the user. */ phoneNumber: string = "+1234567890" } ``` </APIMethod> Then, you can reset the password by calling `resetPassword` on the client with the OTP code and the new password. <APIMethod path="/phone-number/reset-password" method="POST"> ```ts type resetPasswordPhoneNumber = { /** * The one time password to reset the password. */ otp: string = "123456" /** * The phone number to the account which intends to reset the password for. */ phoneNumber: string = "+1234567890" /** * The new password. */ newPassword: string = "new-and-secure-password" } ``` </APIMethod> ## Options - `otpLength`: The length of the OTP code to be generated. Default is `6`. - `sendOTP`: A function that sends the OTP code to the user's phone number. It takes the phone number and the OTP code as arguments. - `expiresIn`: The time in seconds after which the OTP code expires. Default is `300` seconds. - `callbackOnVerification`: A function that is called after the phone number is verified. It takes the phone number and the user object as the first argument and a request object as the second argument. ```ts export const auth = betterAuth({ plugins: [ phoneNumber({ sendOTP: ({ phoneNumber, code }, request) => { // Implement sending OTP code via SMS }, callbackOnVerification: async ({ phoneNumber, user }, request) => { // Implement callback after phone number verification } }) ] }) ``` - `sendPasswordResetOTP`: A function that sends the OTP code to the user's phone number for password reset. It takes the phone number and the OTP code as arguments. - `phoneNumberValidator`: A custom function to validate the phone number. It takes the phone number as an argument and returns a boolean indicating whether the phone number is valid. - `signUpOnVerification`: An object with the following properties: - `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email. - `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name. - `requireVerification`: When enabled, users cannot sign in with their phone number until it has been verified. If an unverified user attempts to sign in, the server will respond with a 401 error (PHONE_NUMBER_NOT_VERIFIED) and automatically trigger an OTP send to start the verification process. ## Schema The plugin requires 2 fields to be added to the user table ### User Table <DatabaseTable fields={[ { name: "phoneNumber", type: "string", description: "The phone number of the user", isUnique: true, isOptional: true }, { name: "phoneNumberVerified", type: "boolean", description: "Whether the phone number is verified or not", defaultValue: false, isOptional: true }, ]} /> ### OTP Verification Attempts The phone number plugin includes a built-in protection against brute force attacks by limiting the number of verification attempts for each OTP code. ```typescript phoneNumber({ allowedAttempts: 3, // default is 3 // ... other options }) ``` When a user exceeds the allowed number of verification attempts: - The OTP code is automatically deleted - Further verification attempts will return a 403 (Forbidden) status with "Too many attempts" message - The user will need to request a new OTP code to continue Example error response after exceeding attempts: ```json { "error": { "status": 403, "message": "Too many attempts" } } ``` <Callout type="warning"> When receiving a 403 status, prompt the user to request a new OTP code </Callout> ``` -------------------------------------------------------------------------------- /packages/better-auth/src/cookies/index.ts: -------------------------------------------------------------------------------- ```typescript import type { CookieOptions } from "better-call"; import { BetterAuthError } from "@better-auth/core/error"; import type { Session, User } from "../types"; import type { BetterAuthOptions } from "@better-auth/core"; import { getDate } from "../utils/date"; import { env, isProduction } from "@better-auth/core/env"; import { base64Url } from "@better-auth/utils/base64"; import { ms } from "ms"; import { createHMAC } from "@better-auth/utils/hmac"; import { safeJSONParse } from "../utils/json"; import { getBaseURL } from "../utils/url"; import { binary } from "@better-auth/utils/binary"; import type { BetterAuthCookies, GenericEndpointContext, } from "@better-auth/core"; import { parseUserOutput } from "../db/schema"; export function createCookieGetter(options: BetterAuthOptions) { const secure = options.advanced?.useSecureCookies !== undefined ? options.advanced?.useSecureCookies : options.baseURL !== undefined ? options.baseURL.startsWith("https://") ? true : false : isProduction; const secureCookiePrefix = secure ? "__Secure-" : ""; const crossSubdomainEnabled = !!options.advanced?.crossSubDomainCookies?.enabled; const domain = crossSubdomainEnabled ? options.advanced?.crossSubDomainCookies?.domain || (options.baseURL ? new URL(options.baseURL).hostname : undefined) : undefined; if (crossSubdomainEnabled && !domain) { throw new BetterAuthError( "baseURL is required when crossSubdomainCookies are enabled", ); } function createCookie( cookieName: string, overrideAttributes: Partial<CookieOptions> = {}, ) { const prefix = options.advanced?.cookiePrefix || "better-auth"; const name = options.advanced?.cookies?.[cookieName as "session_token"]?.name || `${prefix}.${cookieName}`; const attributes = options.advanced?.cookies?.[cookieName as "session_token"]?.attributes; return { name: `${secureCookiePrefix}${name}`, attributes: { secure: !!secureCookiePrefix, sameSite: "lax", path: "/", httpOnly: true, ...(crossSubdomainEnabled ? { domain } : {}), ...options.advanced?.defaultCookieAttributes, ...overrideAttributes, ...attributes, } as CookieOptions, }; } return createCookie; } export function getCookies(options: BetterAuthOptions) { const createCookie = createCookieGetter(options); const sessionMaxAge = options.session?.expiresIn || ms("7d") / 1000; const sessionToken = createCookie("session_token", { maxAge: sessionMaxAge, }); const sessionData = createCookie("session_data", { maxAge: options.session?.cookieCache?.maxAge || 60 * 5, }); const dontRememberToken = createCookie("dont_remember"); return { sessionToken: { name: sessionToken.name, options: sessionToken.attributes, }, /** * This cookie is used to store the session data in the cookie * This is useful for when you want to cache the session in the cookie */ sessionData: { name: sessionData.name, options: sessionData.attributes, }, dontRememberToken: { name: dontRememberToken.name, options: dontRememberToken.attributes, }, }; } export async function setCookieCache( ctx: GenericEndpointContext, session: { session: Session & Record<string, any>; user: User; }, dontRememberMe: boolean, ) { const shouldStoreSessionDataInCookie = ctx.context.options.session?.cookieCache?.enabled; if (shouldStoreSessionDataInCookie) { const filteredSession = Object.entries(session.session).reduce( (acc, [key, value]) => { const fieldConfig = ctx.context.options.session?.additionalFields?.[key]; if (!fieldConfig || fieldConfig.returned !== false) { acc[key] = value; } return acc; }, {} as Record<string, any>, ); // Apply field filtering to user data const filteredUser = parseUserOutput(ctx.context.options, session.user); const sessionData = { session: filteredSession, user: filteredUser }; const options = { ...ctx.context.authCookies.sessionData.options, maxAge: dontRememberMe ? undefined : ctx.context.authCookies.sessionData.options.maxAge, }; const expiresAtDate = getDate(options.maxAge || 60, "sec").getTime(); const data = base64Url.encode( JSON.stringify({ session: sessionData, expiresAt: expiresAtDate, signature: await createHMAC("SHA-256", "base64urlnopad").sign( ctx.context.secret, JSON.stringify({ ...sessionData, expiresAt: expiresAtDate, }), ), }), { padding: false, }, ); if (data.length > 4093) { ctx.context?.logger?.error( `Session data exceeds cookie size limit (${data.length} bytes > 4093 bytes). Consider reducing session data size or disabling cookie cache. Session will not be cached in cookie.`, ); return; } ctx.setCookie(ctx.context.authCookies.sessionData.name, data, options); } } export async function setSessionCookie( ctx: GenericEndpointContext, session: { session: Session & Record<string, any>; user: User; }, dontRememberMe?: boolean, overrides?: Partial<CookieOptions>, ) { const dontRememberMeCookie = await ctx.getSignedCookie( ctx.context.authCookies.dontRememberToken.name, ctx.context.secret, ); // if dontRememberMe is not set, use the cookie value dontRememberMe = dontRememberMe !== undefined ? dontRememberMe : !!dontRememberMeCookie; const options = ctx.context.authCookies.sessionToken.options; const maxAge = dontRememberMe ? undefined : ctx.context.sessionConfig.expiresIn; await ctx.setSignedCookie( ctx.context.authCookies.sessionToken.name, session.session.token, ctx.context.secret, { ...options, maxAge, ...overrides, }, ); if (dontRememberMe) { await ctx.setSignedCookie( ctx.context.authCookies.dontRememberToken.name, "true", ctx.context.secret, ctx.context.authCookies.dontRememberToken.options, ); } await setCookieCache(ctx, session, dontRememberMe); ctx.context.setNewSession(session); /** * If secondary storage is enabled, store the session data in the secondary storage * This is useful if the session got updated and we want to update the session data in the * secondary storage */ if (ctx.context.options.secondaryStorage) { await ctx.context.secondaryStorage?.set( session.session.token, JSON.stringify({ user: session.user, session: session.session, }), Math.floor( (new Date(session.session.expiresAt).getTime() - Date.now()) / 1000, ), ); } } export function deleteSessionCookie( ctx: GenericEndpointContext, skipDontRememberMe?: boolean, ) { ctx.setCookie(ctx.context.authCookies.sessionToken.name, "", { ...ctx.context.authCookies.sessionToken.options, maxAge: 0, }); ctx.setCookie(ctx.context.authCookies.sessionData.name, "", { ...ctx.context.authCookies.sessionData.options, maxAge: 0, }); if (!skipDontRememberMe) { ctx.setCookie(ctx.context.authCookies.dontRememberToken.name, "", { ...ctx.context.authCookies.dontRememberToken.options, maxAge: 0, }); } } export function parseCookies(cookieHeader: string) { const cookies = cookieHeader.split("; "); const cookieMap = new Map<string, string>(); cookies.forEach((cookie) => { const [name, value] = cookie.split("="); cookieMap.set(name!, value!); }); return cookieMap; } export type EligibleCookies = (string & {}) | (keyof BetterAuthCookies & {}); export const getSessionCookie = ( request: Request | Headers, config?: { cookiePrefix?: string; cookieName?: string; path?: string; }, ) => { if (config?.cookiePrefix) { if (config.cookieName) { config.cookiePrefix = `${config.cookiePrefix}-`; } else { config.cookiePrefix = `${config.cookiePrefix}.`; } } const headers = "headers" in request ? request.headers : request; const req = request instanceof Request ? request : undefined; const url = getBaseURL(req?.url, config?.path, req); const cookies = headers.get("cookie"); if (!cookies) { return null; } const { cookieName = "session_token", cookiePrefix = "better-auth." } = config || {}; const name = `${cookiePrefix}${cookieName}`; const secureCookieName = `__Secure-${name}`; const parsedCookie = parseCookies(cookies); const sessionToken = parsedCookie.get(name) || parsedCookie.get(secureCookieName); if (sessionToken) { return sessionToken; } return null; }; export const getCookieCache = async < S extends { session: Session & Record<string, any>; user: User & Record<string, any>; }, >( request: Request | Headers, config?: { cookiePrefix?: string; cookieName?: string; isSecure?: boolean; secret?: string; }, ) => { const headers = request instanceof Headers ? request : request.headers; const cookies = headers.get("cookie"); if (!cookies) { return null; } const { cookieName = "session_data", cookiePrefix = "better-auth" } = config || {}; const name = config?.isSecure !== undefined ? config.isSecure ? `__Secure-${cookiePrefix}.${cookieName}` : `${cookiePrefix}.${cookieName}` : isProduction ? `__Secure-${cookiePrefix}.${cookieName}` : `${cookiePrefix}.${cookieName}`; const parsedCookie = parseCookies(cookies); const sessionData = parsedCookie.get(name); if (sessionData) { const sessionDataPayload = safeJSONParse<{ session: S; expiresAt: number; signature: string; }>(binary.decode(base64Url.decode(sessionData))); if (!sessionDataPayload) { return null; } const secret = config?.secret || env.BETTER_AUTH_SECRET; if (!secret) { throw new BetterAuthError( "getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable", ); } const isValid = await createHMAC("SHA-256", "base64urlnopad").verify( secret, JSON.stringify({ ...sessionDataPayload.session, expiresAt: sessionDataPayload.expiresAt, }), sessionDataPayload.signature, ); if (!isValid) { return null; } return sessionDataPayload.session; } return null; }; export * from "./cookie-utils"; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/client/client.test.ts: -------------------------------------------------------------------------------- ```typescript // @vitest-environment happy-dom import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { createAuthClient as createSolidClient } from "./solid"; import { createAuthClient as createReactClient } from "./react"; import { createAuthClient as createVueClient } from "./vue"; import { createAuthClient as createSvelteClient } from "./svelte"; import { createAuthClient as createVanillaClient } from "./vanilla"; import { testClientPlugin, testClientPlugin2 } from "./test-plugin"; import type { Accessor } from "solid-js"; import type { Ref } from "vue"; import type { ReadableAtom } from "nanostores"; import type { Session, SessionQueryParams } from "../types"; import { BetterFetchError } from "@better-fetch/fetch"; import { twoFactorClient } from "../plugins"; import { organizationClient, passkeyClient } from "./plugins"; import { isProxy } from "node:util/types"; describe("run time proxy", async () => { it("atom in proxy should not be proxy", async () => { const client = createVanillaClient(); const atom = client.$store.atoms.session; expect(isProxy(atom)).toBe(false); }); it("proxy api should be called", async () => { let apiCalled = false; const client = createSolidClient({ plugins: [testClientPlugin()], fetchOptions: { customFetchImpl: async (url, init) => { apiCalled = true; return new Response(); }, baseURL: "http://localhost:3000", }, }); await client.test(); expect(apiCalled).toBe(true); }); it("state listener should be called on matched path", async () => { const client = createSolidClient({ plugins: [testClientPlugin()], fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, baseURL: "http://localhost:3000", }, }); const res = client.useComputedAtom(); expect(res()).toBe(0); await client.test(); vi.useFakeTimers(); setTimeout(() => { expect(res()).toBe(1); }, 100); }); it("should call useSession", async () => { let returnNull = false; const client = createSolidClient({ plugins: [testClientPlugin()], fetchOptions: { customFetchImpl: async () => { if (returnNull) { return new Response(JSON.stringify(null)); } return new Response( JSON.stringify({ user: { id: 1, email: "[email protected]", }, }), ); }, baseURL: "http://localhost:3000", }, }); const res = client.useSession(); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(1); expect(res()).toMatchObject({ data: { user: { id: 1, email: "[email protected]" } }, error: null, isPending: false, }); /** * recall */ returnNull = true; await client.test2.signOut(); await vi.advanceTimersByTimeAsync(10); expect(res()).toMatchObject({ data: null, error: null, isPending: false, }); }); it("should allow second argument fetch options", async () => { let called = false; const client = createSolidClient({ plugins: [testClientPlugin()], fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, baseURL: "http://localhost:3000", }, }); await client.test( {}, { onSuccess(context) { called = true; }, }, ); expect(called).toBe(true); }); it("should not expose a 'then', 'catch', 'finally' property on the proxy", async () => { const client = createSolidClient({ plugins: [testClientPlugin()], fetchOptions: { customFetchImpl: async () => new Response(), baseURL: "http://localhost:3000", }, }); const proxy = (client as any).test; expect(proxy.then).toBeUndefined(); expect(proxy.catch).toBeUndefined(); expect(proxy.finally).toBeUndefined(); }); }); describe("type", () => { it("should infer session additional fields", () => { const client = createReactClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); type ReturnedSession = ReturnType<typeof client.useSession>; expectTypeOf<ReturnedSession>().toMatchTypeOf<{ data: { user: { id: string; email: string; emailVerified: boolean; name: string; createdAt: Date; updatedAt: Date; image?: string | undefined | null; testField4: string; testField?: string | undefined | null; testField2?: number | undefined | null; }; session: Session; } | null; error: BetterFetchError | null; isPending: boolean; }>(); }); it("should infer resolved hooks react", () => { const client = createReactClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); expectTypeOf(client.useComputedAtom).toEqualTypeOf<() => number>(); }); it("should infer resolved hooks solid", () => { const client = createSolidClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); expectTypeOf(client.useComputedAtom).toEqualTypeOf< () => Accessor<number> >(); }); it("should infer resolved hooks vue", () => { const client = createVueClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); expectTypeOf(client.useComputedAtom).toEqualTypeOf< () => Readonly<Ref<number>> >(); }); it("should infer resolved hooks svelte", () => { const client = createSvelteClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); expectTypeOf(client.useComputedAtom).toEqualTypeOf< () => ReadableAtom<number> >(); }); it("should infer actions", () => { const client = createSolidClient({ plugins: [testClientPlugin(), testClientPlugin2()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); expectTypeOf(client.setTestAtom).toEqualTypeOf<(value: boolean) => void>(); expectTypeOf(client.test.signOut).toEqualTypeOf<() => Promise<void>>(); }); it("should infer session", () => { const client = createSolidClient({ plugins: [testClientPlugin(), testClientPlugin2(), twoFactorClient()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); const $infer = client.$Infer; expectTypeOf<typeof $infer.Session>().toEqualTypeOf<{ session: { id: string; userId: string; expiresAt: Date; token: string; ipAddress?: string | undefined | null; userAgent?: string | undefined | null; createdAt: Date; updatedAt: Date; }; user: { id: string; email: string; emailVerified: boolean; name: string; createdAt: Date; updatedAt: Date; image?: string | undefined | null; testField4: string; testField?: string | undefined | null; testField2?: number | undefined | null; twoFactorEnabled: boolean | undefined | null; }; }>(); }); it("should infer session react", () => { const client = createReactClient({ plugins: [organizationClient(), twoFactorClient(), passkeyClient()], }); const $infer = client.$Infer.Session; expectTypeOf<typeof $infer.user>().toEqualTypeOf<{ name: string; id: string; email: string; emailVerified: boolean; createdAt: Date; updatedAt: Date; image?: string | undefined | null; twoFactorEnabled: boolean | undefined | null; }>(); }); it("should infer `throw:true` in fetch options", async () => { const client = createReactClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { throw: true, customFetchImpl: async (url, init) => { return new Response(); }, }, }); const data = client.getSession(); expectTypeOf(data).toMatchTypeOf< Promise<{ user: { id: string; email: string; emailVerified: boolean; name: string; createdAt: Date; updatedAt: Date; image?: string | undefined | null; testField4: string; testField?: string | undefined | null; testField2?: number | undefined | null; }; session: { id: string; userId: string; expiresAt: Date; ipAddress?: string | undefined | null; userAgent?: string | undefined | null; }; } | null> >(); }); it("should infer `error` schema correctly", async () => { const client = createSolidClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); const { error } = await client.test(); expectTypeOf(error!).toMatchObjectType<{ code: number; message: string; test: boolean; }>(); }); it("should support refetch with query parameters", () => { const client = createReactClient({ plugins: [testClientPlugin()], baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: async (url, init) => { return new Response(); }, }, }); type UseSessionReturn = ReturnType<typeof client.useSession>; expectTypeOf<UseSessionReturn>().toMatchTypeOf<{ data: { user: { id: string; email: string; emailVerified: boolean; name: string; createdAt: Date; updatedAt: Date; image?: string | undefined | null; testField4: string; testField?: string | undefined | null; testField2?: number | undefined | null; }; session: Session; } | null; isPending: boolean; error: BetterFetchError | null; refetch: (queryParams?: { query?: SessionQueryParams }) => void; }>(); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-members.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from "vitest"; import { getTestInstance } from "../../../test-utils/test-instance"; import { organization } from "../organization"; import { createAuthClient } from "../../../client"; import { organizationClient } from "../client"; import { ORGANIZATION_ERROR_CODES } from "../error-codes"; describe("listMembers", async () => { const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({ plugins: [organization()], }); const ctx = await auth.$context; const { headers } = await signInWithTestUser(); const client = createAuthClient({ plugins: [organizationClient()], baseURL: "http://localhost:3000/api/auth", fetchOptions: { customFetchImpl: async (url, init) => { return auth.handler(new Request(url, init)); }, }, }); const org = await client.organization.create({ name: "test", slug: "test", metadata: { test: "test", }, fetchOptions: { headers, }, }); const secondOrg = await client.organization.create({ name: "test-second", slug: "test-second", metadata: { test: "second-org", }, fetchOptions: { headers, }, }); for (let i = 0; i < 10; i++) { const user = await ctx.adapter.create({ model: "user", data: { email: `test${i}@test.com`, name: `test${i}`, }, }); await auth.api.addMember({ body: { organizationId: org.data?.id as string, userId: user.id, role: "member", }, }); } it("should return all members", async () => { await client.organization.setActive({ organizationId: org.data?.id as string, fetchOptions: { headers, }, }); const members = await client.organization.listMembers({ fetchOptions: { headers, }, }); expect(members.data?.members.length).toBe(11); expect(members.data?.total).toBe(11); }); it("should limit the number of members", async () => { const members = await client.organization.listMembers({ fetchOptions: { headers, }, query: { limit: 5, }, }); expect(members.data?.members.length).toBe(5); expect(members.data?.total).toBe(11); }); it("should offset the members", async () => { const members = await client.organization.listMembers({ fetchOptions: { headers, }, query: { offset: 5, }, }); expect(members.data?.members.length).toBe(6); expect(members.data?.total).toBe(11); }); it("should filter the members", async () => { const members = await client.organization.listMembers({ fetchOptions: { headers, }, query: { filterField: "createdAt", filterOperator: "gt", filterValue: new Date( Date.now() - 1000 * 60 * 60 * 24 * 30, ).toISOString(), }, }); expect(members.data?.members.length).toBe(0); expect(members.data?.total).toBe(0); }); it("should sort the members", async () => { const defaultMembers = await client.organization.listMembers({ fetchOptions: { headers, }, }); const firstMember = defaultMembers.data?.members[0]; if (!firstMember) { throw new Error("No first member found"); } const secondMember = defaultMembers.data?.members[1]; if (!secondMember) { throw new Error("No second member found"); } await ctx.adapter.update({ model: "member", where: [{ field: "id", value: secondMember.id }], update: { // update the second member to be the oldest createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), }, }); const lastMember = defaultMembers.data?.members[defaultMembers.data?.members.length - 1]; if (!lastMember) { throw new Error("No last member found"); } const oneBeforeLastMember = defaultMembers.data?.members[defaultMembers.data?.members.length - 2]; if (!oneBeforeLastMember) { throw new Error("No one before last member found"); } await ctx.adapter.update({ model: "member", where: [{ field: "id", value: oneBeforeLastMember.id }], update: { // update the one before last member to be the newest createdAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), }, }); const members = await client.organization.listMembers({ fetchOptions: { headers, }, query: { sortBy: "createdAt", sortDirection: "asc", }, }); expect(members.data?.members[0]!.id).not.toBe(firstMember.id); expect( members.data?.members[members.data?.members.length - 1]!.id, ).not.toBe(lastMember.id); expect(members.data?.members[0]!.id).toBe(secondMember.id); expect(members.data?.members[members.data?.members.length - 1]!.id).toBe( oneBeforeLastMember.id, ); }); it("should list members by organization id", async () => { const members = await client.organization.listMembers({ fetchOptions: { headers, }, query: { organizationId: secondOrg.data?.id as string, }, }); expect(members.data?.members.length).toBe(1); expect(members.data?.total).toBe(1); }); it("should not list members if not a member", async () => { const newHeaders = new Headers(); await client.signUp.email({ email: "[email protected]", name: "test22", password: "password", fetchOptions: { onSuccess: cookieSetter(newHeaders), }, }); const members = await client.organization.listMembers({ fetchOptions: { headers: newHeaders, }, query: { organizationId: org.data?.id as string, }, }); expect(members.error).toBeTruthy(); expect(members.error?.message).toBe( ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION, ); }); }); describe("updateMemberRole", async () => { const { auth, signInWithTestUser, cookieSetter, customFetchImpl } = await getTestInstance({ plugins: [organization()], }); it("should update the member role", async () => { const { headers, user } = await signInWithTestUser(); const client = createAuthClient({ plugins: [organizationClient()], baseURL: "http://localhost:3000/api/auth", fetchOptions: { customFetchImpl, }, }); const org = await client.organization.create({ name: "test", slug: "test", metadata: { test: "test", }, fetchOptions: { headers, }, }); const newUser = await auth.api.signUpEmail({ body: { email: "[email protected]", name: "test", password: "password", }, }); const member = await auth.api.addMember({ body: { organizationId: org.data?.id as string, userId: newUser.user.id, role: "member", }, }); const updatedMember = await client.organization.updateMemberRole( { organizationId: org.data?.id as string, memberId: member?.id as string, role: "admin", }, { headers, }, ); expect(updatedMember.data?.role).toBe("admin"); }); it("should not update the member role if the member updating is not a member ", async () => { const { headers, user } = await signInWithTestUser(); const client = createAuthClient({ plugins: [organizationClient()], baseURL: "http://localhost:3000/api/auth", fetchOptions: { customFetchImpl, }, }); const org = await client.organization.create({ name: "test", slug: "test", metadata: { test: "test", }, fetchOptions: { headers, }, }); const newUser = await auth.api.signUpEmail({ body: { email: "[email protected]", name: "test", password: "password", }, }); const newOrg = await client.organization.create( { name: "test2", slug: "test2", metadata: { test: "test", }, }, { headers: new Headers({ authorization: `Bearer ${newUser.token}`, }), }, ); await auth.api.addMember({ body: { organizationId: newOrg.data?.id as string, userId: user.id, role: "admin", }, }); const updatedMember = await client.organization.updateMemberRole( { organizationId: newOrg.data?.id as string, memberId: newOrg.data?.members[0]?.id as string, role: "admin", }, { headers, }, ); expect(updatedMember.error).toBeTruthy(); expect(updatedMember.error?.message).toBe( ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER, ); }); }); describe("activeMemberRole", async () => { const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({ plugins: [organization()], }); const ctx = await auth.$context; const { headers } = await signInWithTestUser(); const client = createAuthClient({ plugins: [organizationClient()], baseURL: "http://localhost:3000/api/auth", fetchOptions: { customFetchImpl: async (url, init) => { return auth.handler(new Request(url, init)); }, }, }); const org = await client.organization.create({ name: "test", slug: "test", metadata: { test: "test", }, fetchOptions: { headers, }, }); const secondOrg = await client.organization.create({ name: "test-second", slug: "test-second", metadata: { test: "second-org", }, fetchOptions: { headers, }, }); let selectedUserId = ""; for (let i = 0; i < 10; i++) { const user = await ctx.adapter.create({ model: "user", data: { email: `test${i}@test.com`, name: `test${i}`, }, }); if (i == 0) { selectedUserId = user.id; } await auth.api.addMember({ body: { organizationId: org.data?.id as string, userId: user.id, role: "member", }, }); } it("should return the active member role on active organization", async () => { await client.organization.setActive({ organizationId: org.data?.id as string, fetchOptions: { headers, }, }); const activeMember = await client.organization.getActiveMemberRole({ fetchOptions: { headers, }, }); expect(activeMember.data?.role).toBe("owner"); }); it("should return active member role on organization", async () => { await client.organization.setActive({ organizationId: org.data?.id as string, fetchOptions: { headers, }, }); const activeMember = await client.organization.getActiveMemberRole({ query: { userId: selectedUserId, }, fetchOptions: { headers, }, }); expect(activeMember.data?.role).toBe("member"); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/index.ts: -------------------------------------------------------------------------------- ```typescript import { APIError } from "../../api"; import { createAuthMiddleware } from "@better-auth/core/api"; import type { BetterAuthPlugin } from "@better-auth/core"; import { mergeSchema } from "../../db"; import { apiKeySchema } from "./schema"; import { getIp } from "../../utils/get-request-ip"; import { getDate } from "../../utils/date"; import type { ApiKeyOptions } from "./types"; import { createApiKeyRoutes, deleteAllExpiredApiKeys } from "./routes"; import { validateApiKey } from "./routes/verify-api-key"; import { base64Url } from "@better-auth/utils/base64"; import { createHash } from "@better-auth/utils/hash"; import { defineErrorCodes } from "@better-auth/core/utils"; export const defaultKeyHasher = async (key: string) => { const hash = await createHash("SHA-256").digest( new TextEncoder().encode(key), ); const hashed = base64Url.encode(new Uint8Array(hash), { padding: false, }); return hashed; }; export const ERROR_CODES = defineErrorCodes({ INVALID_METADATA_TYPE: "metadata must be an object or undefined", REFILL_AMOUNT_AND_INTERVAL_REQUIRED: "refillAmount is required when refillInterval is provided", REFILL_INTERVAL_AND_AMOUNT_REQUIRED: "refillInterval is required when refillAmount is provided", USER_BANNED: "User is banned", UNAUTHORIZED_SESSION: "Unauthorized or invalid session", KEY_NOT_FOUND: "API Key not found", KEY_DISABLED: "API Key is disabled", KEY_EXPIRED: "API Key has expired", USAGE_EXCEEDED: "API Key has reached its usage limit", KEY_NOT_RECOVERABLE: "API Key is not recoverable", EXPIRES_IN_IS_TOO_SMALL: "The expiresIn is smaller than the predefined minimum value.", EXPIRES_IN_IS_TOO_LARGE: "The expiresIn is larger than the predefined maximum value.", INVALID_REMAINING: "The remaining count is either too large or too small.", INVALID_PREFIX_LENGTH: "The prefix length is either too large or too small.", INVALID_NAME_LENGTH: "The name length is either too large or too small.", METADATA_DISABLED: "Metadata is disabled.", RATE_LIMIT_EXCEEDED: "Rate limit exceeded.", NO_VALUES_TO_UPDATE: "No values to update.", KEY_DISABLED_EXPIRATION: "Custom key expiration values are disabled.", INVALID_API_KEY: "Invalid API key.", INVALID_USER_ID_FROM_API_KEY: "The user id from the API key is invalid.", INVALID_API_KEY_GETTER_RETURN_TYPE: "API Key getter returned an invalid key type. Expected string.", SERVER_ONLY_PROPERTY: "The property you're trying to set can only be set from the server auth instance only.", FAILED_TO_UPDATE_API_KEY: "Failed to update API key", NAME_REQUIRED: "API Key name is required.", }); export const API_KEY_TABLE_NAME = "apikey"; export const apiKey = (options?: ApiKeyOptions) => { const opts = { ...options, apiKeyHeaders: options?.apiKeyHeaders ?? "x-api-key", defaultKeyLength: options?.defaultKeyLength || 64, maximumPrefixLength: options?.maximumPrefixLength ?? 32, minimumPrefixLength: options?.minimumPrefixLength ?? 1, maximumNameLength: options?.maximumNameLength ?? 32, minimumNameLength: options?.minimumNameLength ?? 1, enableMetadata: options?.enableMetadata ?? false, disableKeyHashing: options?.disableKeyHashing ?? false, requireName: options?.requireName ?? false, rateLimit: { enabled: options?.rateLimit?.enabled === undefined ? true : options?.rateLimit?.enabled, timeWindow: options?.rateLimit?.timeWindow ?? 1000 * 60 * 60 * 24, maxRequests: options?.rateLimit?.maxRequests ?? 10, }, keyExpiration: { defaultExpiresIn: options?.keyExpiration?.defaultExpiresIn ?? null, disableCustomExpiresTime: options?.keyExpiration?.disableCustomExpiresTime ?? false, maxExpiresIn: options?.keyExpiration?.maxExpiresIn ?? 365, minExpiresIn: options?.keyExpiration?.minExpiresIn ?? 1, }, startingCharactersConfig: { shouldStore: options?.startingCharactersConfig?.shouldStore ?? true, charactersLength: options?.startingCharactersConfig?.charactersLength ?? 6, }, enableSessionForAPIKeys: options?.enableSessionForAPIKeys ?? false, } satisfies ApiKeyOptions; const schema = mergeSchema( apiKeySchema({ rateLimitMax: opts.rateLimit.maxRequests, timeWindow: opts.rateLimit.timeWindow, }), opts.schema, ); const getter = opts.customAPIKeyGetter || ((ctx) => { if (Array.isArray(opts.apiKeyHeaders)) { for (const header of opts.apiKeyHeaders) { const value = ctx.headers?.get(header); if (value) { return value; } } } else { return ctx.headers?.get(opts.apiKeyHeaders); } }); const keyGenerator = opts.customKeyGenerator || (async (options: { length: number; prefix: string | undefined }) => { const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; let apiKey = `${options.prefix || ""}`; for (let i = 0; i < options.length; i++) { const randomIndex = Math.floor(Math.random() * characters.length); apiKey += characters[randomIndex]; } return apiKey; }); const routes = createApiKeyRoutes({ keyGenerator, opts, schema }); return { id: "api-key", $ERROR_CODES: ERROR_CODES, hooks: { before: [ { matcher: (ctx) => !!getter(ctx) && opts.enableSessionForAPIKeys, handler: createAuthMiddleware(async (ctx) => { const key = getter(ctx)!; if (typeof key !== "string") { throw new APIError("BAD_REQUEST", { message: ERROR_CODES.INVALID_API_KEY_GETTER_RETURN_TYPE, }); } if (key.length < opts.defaultKeyLength) { // if the key is shorter than the default key length, than we know the key is invalid. // we can't check if the key is exactly equal to the default key length, because // a prefix may be added to the key. throw new APIError("FORBIDDEN", { message: ERROR_CODES.INVALID_API_KEY, }); } if (opts.customAPIKeyValidator) { const isValid = await opts.customAPIKeyValidator({ ctx, key }); if (!isValid) { throw new APIError("FORBIDDEN", { message: ERROR_CODES.INVALID_API_KEY, }); } } const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); const apiKey = await validateApiKey({ hashedKey: hashed, ctx, opts, schema, }); //for cleanup purposes deleteAllExpiredApiKeys(ctx.context).catch((err) => { ctx.context.logger.error( "Failed to delete expired API keys:", err, ); }); const user = await ctx.context.internalAdapter.findUserById( apiKey.userId, ); if (!user) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.INVALID_USER_ID_FROM_API_KEY, }); } const session = { user, session: { id: apiKey.id, token: key, userId: apiKey.userId, userAgent: ctx.request?.headers.get("user-agent") ?? null, ipAddress: ctx.request ? getIp(ctx.request, ctx.context.options) : null, createdAt: new Date(), updatedAt: new Date(), expiresAt: apiKey.expiresAt || getDate( ctx.context.options.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days "ms", ), }, }; // Always set the session context for API key authentication ctx.context.session = session; if (ctx.path === "/get-session") { return session; } else { return { context: ctx, }; } }), }, ], }, endpoints: { /** * ### Endpoint * * POST `/api-key/create` * * ### API Methods * * **server:** * `auth.api.createApiKey` * * **client:** * `authClient.apiKey.create` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-create) */ createApiKey: routes.createApiKey, /** * ### Endpoint * * POST `/api-key/verify` * * ### API Methods * * **server:** * `auth.api.verifyApiKey` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-verify) */ verifyApiKey: routes.verifyApiKey, /** * ### Endpoint * * GET `/api-key/get` * * ### API Methods * * **server:** * `auth.api.getApiKey` * * **client:** * `authClient.apiKey.get` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-get) */ getApiKey: routes.getApiKey, /** * ### Endpoint * * POST `/api-key/update` * * ### API Methods * * **server:** * `auth.api.updateApiKey` * * **client:** * `authClient.apiKey.update` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-update) */ updateApiKey: routes.updateApiKey, /** * ### Endpoint * * POST `/api-key/delete` * * ### API Methods * * **server:** * `auth.api.deleteApiKey` * * **client:** * `authClient.apiKey.delete` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-delete) */ deleteApiKey: routes.deleteApiKey, /** * ### Endpoint * * GET `/api-key/list` * * ### API Methods * * **server:** * `auth.api.listApiKeys` * * **client:** * `authClient.apiKey.list` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-list) */ listApiKeys: routes.listApiKeys, /** * ### Endpoint * * POST `/api-key/delete-all-expired-api-keys` * * ### API Methods * * **server:** * `auth.api.deleteAllExpiredApiKeys` * * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/api-key#api-method-api-key-delete-all-expired-api-keys) */ deleteAllExpiredApiKeys: routes.deleteAllExpiredApiKeys, }, schema, } satisfies BetterAuthPlugin; }; ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/next.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Next.js integration description: Integrate Better Auth with Next.js. --- Better Auth can be easily integrated with Next.js. Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation). ### Create API Route We need to mount the handler to an API route. Create a route file inside `/api/auth/[...all]` directory. And add the following code: ```ts title="api/auth/[...all]/route.ts" import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { GET, POST } = toNextJsHandler(auth.handler); ``` <Callout type="info"> You can change the path on your better-auth configuration but it's recommended to keep it as `/api/auth/[...all]` </Callout> For `pages` route, you need to use `toNodeHandler` instead of `toNextJsHandler` and set `bodyParser` to `false` in the `config` object. Here is an example: ```ts title="pages/api/auth/[...all].ts" import { toNodeHandler } from "better-auth/node" import { auth } from "@/lib/auth" // Disallow body parsing, we will parse it manually export const config = { api: { bodyParser: false } } export default toNodeHandler(auth.handler) ``` ## Create a client Create a client instance. You can name the file anything you want. Here we are creating `client.ts` file inside the `lib/` directory. ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/react" // make sure to import from better-auth/react export const authClient = createAuthClient({ //you can pass client configuration here }) ``` Once you have created the client, you can use it to sign up, sign in, and perform other actions. Some of the actions are reactive. The client uses [nano-store](https://github.com/nanostores/nanostores) to store the state and re-render the components when the state changes. The client also uses [better-fetch](https://github.com/bekacru/better-fetch) to make the requests. You can pass the fetch configuration to the client. ## RSC and Server actions The `api` object exported from the auth instance contains all the actions that you can perform on the server. Every endpoint made inside Better Auth is a invocable as a function. Including plugins endpoints. **Example: Getting Session on a server action** ```tsx title="server.ts" import { auth } from "@/lib/auth" import { headers } from "next/headers" const someAuthenticatedAction = async () => { "use server"; const session = await auth.api.getSession({ headers: await headers() }) }; ``` **Example: Getting Session on a RSC** ```tsx import { auth } from "@/lib/auth" import { headers } from "next/headers" export async function ServerComponent() { const session = await auth.api.getSession({ headers: await headers() }) if(!session) { return <div>Not authenticated</div> } return ( <div> <h1>Welcome {session.user.name}</h1> </div> ) } ``` <Callout type="warn">As RSCs cannot set cookies, the [cookie cache](/docs/concepts/session-management#cookie-cache) will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers.</Callout> ### Server Action Cookies When you call a function that needs to set cookies, like `signInEmail` or `signUpEmail` in a server action, cookies won’t be set. This is because server actions need to use the `cookies` helper from Next.js to set cookies. To simplify this, you can use the `nextCookies` plugin, which will automatically set cookies for you whenever a `Set-Cookie` header is present in the response. ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { nextCookies } from "better-auth/next-js"; export const auth = betterAuth({ //...your config plugins: [nextCookies()] // make sure this is the last plugin in the array // [!code highlight] }) ``` Now, when you call functions that set cookies, they will be automatically set. ```ts "use server"; import { auth } from "@/lib/auth" const signIn = async () => { await auth.api.signInEmail({ body: { email: "[email protected]", password: "password", } }) } ``` ## Middleware In Next.js middleware, it's recommended to only check for the existence of a session cookie to handle redirection. To avoid blocking requests by making API or database calls. You can use the `getSessionCookie` helper from Better Auth for this purpose: <Callout type="warn"> The <code>getSessionCookie()</code> function does not automatically reference the auth config specified in <code>auth.ts</code>. Therefore, if you customized the cookie name or prefix, you need to ensure that the configuration in <code>getSessionCookie()</code> matches the config defined in your <code>auth.ts</code>. </Callout> ```ts import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; export async function middleware(request: NextRequest) { const sessionCookie = getSessionCookie(request); // THIS IS NOT SECURE! // This is the recommended approach to optimistically redirect users // We recommend handling auth checks in each page/route if (!sessionCookie) { return NextResponse.redirect(new URL("/", request.url)); } return NextResponse.next(); } export const config = { matcher: ["/dashboard"], // Specify the routes the middleware applies to }; ``` <Callout type="warn"> **Security Warning:** The `getSessionCookie` function only checks for the existence of a session cookie; it does **not** validate it. Relying solely on this check for security is dangerous, as anyone can manually create a cookie to bypass it. You must always validate the session on your server for any protected actions or pages. </Callout> <Callout type="info"> If you have a custom cookie name or prefix, you can pass it to the `getSessionCookie` function. ```ts const sessionCookie = getSessionCookie(request, { cookieName: "my_session_cookie", cookiePrefix: "my_prefix" }); ``` </Callout> Alternatively, you can use the `getCookieCache` helper to get the session object from the cookie cache. ```ts import { getCookieCache } from "better-auth/cookies"; export async function middleware(request: NextRequest) { const session = await getCookieCache(request); if (!session) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next(); } ``` ### How to handle auth checks in each page/route In this example, we are using the `auth.api.getSession` function within a server component to get the session object, then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page. ```tsx title="app/dashboard/page.tsx" import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; export default async function DashboardPage() { const session = await auth.api.getSession({ headers: await headers() }) if(!session) { redirect("/sign-in") } return ( <div> <h1>Welcome {session.user.name}</h1> </div> ) } ``` ### For Next.js release `15.1.7` and below If you need the full session object, you'll have to fetch it from the `/get-session` API route. Since Next.js middleware doesn't support running Node.js APIs directly, you must make an HTTP request. <Callout> The example uses [better-fetch](https://better-fetch.vercel.app), but you can use any fetch library. </Callout> ```ts import { betterFetch } from "@better-fetch/fetch"; import type { auth } from "@/lib/auth"; import { NextRequest, NextResponse } from "next/server"; type Session = typeof auth.$Infer.Session; export async function middleware(request: NextRequest) { const { data: session } = await betterFetch<Session>("/api/auth/get-session", { baseURL: request.nextUrl.origin, headers: { cookie: request.headers.get("cookie") || "", // Forward the cookies from the request }, }); if (!session) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next(); } export const config = { matcher: ["/dashboard"], // Apply middleware to specific routes }; ``` ### For Next.js release `15.2.0` and above From the version 15.2.0, Next.js allows you to use the `Node.js` runtime in middleware. This means you can use the `auth.api` object directly in middleware. <Callout type="warn"> You may refer to the [Next.js documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware#runtime) for more information about runtime configuration, and how to enable it. Be careful when using the new runtime. It's an experimental feature and it may be subject to breaking changes. </Callout> ```ts import { NextRequest, NextResponse } from "next/server"; import { headers } from "next/headers"; import { auth } from "@/lib/auth"; export async function middleware(request: NextRequest) { const session = await auth.api.getSession({ headers: await headers() }) if(!session) { return NextResponse.redirect(new URL("/sign-in", request.url)); } return NextResponse.next(); } export const config = { runtime: "nodejs", matcher: ["/dashboard"], // Apply middleware to specific routes }; ``` ## Next.js 16 Compatibility Better Auth is fully compatible with Next.js 16. You can refer to the [Next.js 16 beta](https://nextjs.org/blog/next-16-beta) for more details on the new features and changes. Here are some important changes to note when using Better Auth with Next.js 16: ### Middleware File Rename In Next.js 16, the `middleware.ts` file convention has been deprecated in favor of `proxy.ts`. To migrate: 1. Rename your `middleware.ts` file to `proxy.ts` 2. All functionality remains the same - just the filename changes ```bash # Rename your middleware file mv middleware.ts proxy.ts ``` <Callout type="info"> The changes above are related to Next.js 16 updates. All Better Auth functionality remains the same. </Callout> ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/supabase-migration-guide.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Migrating from Supabase Auth to Better Auth description: A step-by-step guide to transitioning from Supabase Auth to Better Auth. --- In this guide, we'll walk through the steps to migrate a project from Supabase Auth to Better Auth. <Callout type="warn"> This migration will invalidate all active sessions. While this guide doesn't currently cover migrating two-factor (2FA) or Row Level Security (RLS) configurations, both should be possible with additional steps. </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. Copy your `DATABASE_URL` from your Supabase project and use it to connect to your database. And for this example, we'll need to install `pg` to connect to the database. ```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. ```ts title="auth.ts" import { admin, anonymous } from "better-auth/plugins"; export const auth = betterAuth({ database: new Pool({ connectionString: process.env.DATABASE_URL }), emailVerification: { sendEmailVerification: async(user)=>{ // send email verification email // implement your own logic here } }, emailAndPassword: { // [!code highlight] enabled: true, // [!code highlight] } // [!code highlight] }) ``` </Step> <Step> ### Setup Social Providers (Optional) Add social providers you have enabled in your Supabase project in your auth config. ```ts title="auth.ts" import { admin, anonymous } from "better-auth/plugins"; export const auth = betterAuth({ database: new Pool({ connectionString: process.env.DATABASE_URL }), emailAndPassword: { enabled: true, }, socialProviders: { // [!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 admin and anonymous plugins (Optional) Add the [admin](/docs/plugins/admin) and [anonymous](/docs/plugins/anonymous) plugins to your auth config. ```ts title="auth.ts" import { admin, anonymous } from "better-auth/plugins"; export const auth = betterAuth({ database: new Pool({ connectionString: process.env.DATABASE_URL }), emailAndPassword: { enabled: true, }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, } }, plugins: [admin(), anonymous()], // [!code highlight] }) ``` </Step> <Step> ### Run the migration Run the migration to create the necessary tables in your database. ```bash title="Terminal" npx @better-auth/cli migrate ``` This will create the following tables in your database: - [`user`](/docs/concepts/database#user) - [`account`](/docs/concepts/database#account) - [`session`](/docs/concepts/database#session) - [`verification`](/docs/concepts/database#verification) This tables will be created on the `public` schema. </Step> <Step> ### Copy the migration script Now that we have the necessary tables in our database, we can run the migration script to migrate the users and accounts from Supabase to Better Auth. Start by creating a `.ts` file in your project. ```bash title="Terminal" touch migration.ts ``` And then copy and paste the following code into the file. ```ts title="migration.ts" import { Pool } from "pg"; import { auth } from "./auth"; import { User as SupabaseUser } from "@supabase/supabase-js"; type User = SupabaseUser & { is_super_admin: boolean; raw_user_meta_data: { avatar_url: string; }; encrypted_password: string; email_confirmed_at: string; created_at: string; updated_at: string; is_anonymous: boolean; identities: { provider: string; identity_data: { sub: string; email: string; }; created_at: string; updated_at: string; }; }; const migrateFromSupabase = async () => { const ctx = await auth.$context; const db = ctx.options.database as Pool; const users = await db .query(` SELECT u.*, COALESCE( json_agg( i.* ORDER BY i.id ) FILTER (WHERE i.id IS NOT NULL), '[]'::json ) as identities FROM auth.users u LEFT JOIN auth.identities i ON u.id = i.user_id GROUP BY u.id `) .then((res) => res.rows as User[]); for (const user of users) { if (!user.email) { continue; } await ctx.adapter .create({ model: "user", data: { id: user.id, email: user.email, name: user.email, role: user.is_super_admin ? "admin" : user.role, emailVerified: !!user.email_confirmed_at, image: user.raw_user_meta_data.avatar_url, createdAt: new Date(user.created_at), updatedAt: new Date(user.updated_at), isAnonymous: user.is_anonymous, }, }) .catch(() => {}); for (const identity of user.identities) { const existingAccounts = await ctx.internalAdapter.findAccounts(user.id); if (identity.provider === "email") { const hasCredential = existingAccounts.find( (account) => account.providerId === "credential", ); if (!hasCredential) { await ctx.adapter .create({ model: "account", data: { userId: user.id, providerId: "credential", accountId: user.id, password: user.encrypted_password, createdAt: new Date(user.created_at), updatedAt: new Date(user.updated_at), }, }) .catch(() => {}); } } const supportedProviders = Object.keys(ctx.options.socialProviders || {}) if (supportedProviders.includes(identity.provider)) { const hasAccount = existingAccounts.find( (account) => account.providerId === identity.provider, ); if (!hasAccount) { await ctx.adapter.create({ model: "account", data: { userId: user.id, providerId: identity.provider, accountId: identity.identity_data?.sub, createdAt: new Date(identity.created_at ?? user.created_at), updatedAt: new Date(identity.updated_at ?? user.updated_at), }, }); } } } } }; migrateFromSupabase(); ``` </Step> <Step> ### Customize the migration script (Optional) - `name`: the migration script will use the user's email as the name. You might want to customize it if you have the user display name in your database. - `socialProviderList`: the migration script will use the social providers you have enabled in your auth config. You might want to customize it if you have additional social providers that you haven't enabled in your auth config. - `role`: remove `role` if you're not using the `admin` plugin - `isAnonymous`: remove `isAnonymous` if you're not using the `anonymous` plugin. - update other tables that reference the `users` table to use the `id` field. </Step> <Step> ### Run the migration script Run the migration script to migrate the users and accounts from Supabase to Better Auth. ```bash title="Terminal" bun migration.ts # or use node, ts-node, etc. ``` </Step> <Step> ### Change password hashing algorithm By default, Better Auth uses the `scrypt` algorithm to hash passwords. Since Supabase 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> ### Update your code Update your codebase from Supabase auth calls to Better Auth API. Here's a list of the Supabase auth API calls and their Better Auth counterparts. - `supabase.auth.signUp` -> `authClient.signUp.email` - `supabase.auth.signInWithPassword` -> `authClient.signIn.email` - `supabase.auth.signInWithOAuth` -> `authClient.signIn.social` - `supabase.auth.signInAnonymously` -> `authClient.signIn.anonymous` - `supabase.auth.signOut` -> `authClient.signOut` - `supabase.auth.getSession` -> `authClient.getSession` - you can also use `authClient.useSession` for reactive state Learn more: - [Basic Usage](/docs/basic-usage): Learn how to use the auth client to sign up, sign in, and sign out. - [Email and Password](/docs/authentication/email-and-password): Learn how to add email and password authentication to your project. - [Anonymous](/docs/plugins/anonymous): Learn how to add anonymous authentication to your project. - [Admin](/docs/plugins/admin): Learn how to add admin authentication to your project. - [Email OTP](/docs/authentication/email-otp): Learn how to add email OTP authentication to your project. - [Hooks](/docs/concepts/hooks): Learn how to use the hooks to listen for events. - [Next.js](/docs/integrations/next): Learn how to use the auth client in a Next.js project. </Step> </Steps> ### Middleware To protect routes with middleware, refer to the [Next.js middleware guide](/docs/integrations/next#middleware) or your framework's documentation. ## Wrapping Up Congratulations! You've successfully migrated from Supabase Auth to Better Auth. Better Auth offers greater flexibility and more features—be sure to explore the [documentation](/docs) to unlock its full potential. ```