Better Auth
Add OAuth, magic links, and passkeys with Better Auth
@nualt/medusa-plugin-better-auth
Better Auth as the authentication engine for Medusa v2 — social OAuth, email & password, magic links, passkeys and optional 2FA (via Better Auth plugins) for both customers and admin users, without touching Medusa's native session model.
How it works
Better Auth runs inside your Medusa server, mounted at Copy to clipboard/better-auth/*, with its tables (Copy to clipboardba_*) in your Medusa Postgres. It handles every authentication flow. A Medusa auth module provider (Copy to clipboardbetter-auth) then bridges the result: it validates the Better Auth session and lets Medusa issue its own native tokens for the requested actor (Copy to clipboardcustomer or Copy to clipboarduser). Your protected routes, the admin dashboard and your storefront keep working with standard Medusa auth.
Admin accounts are never created through OAuth: an identity is only linked to an existing invited admin user, and only when the provider verified the email.
Installation
1pnpm add @nualt/medusa-plugin-better-auth better-auth
- Register the plugin and the auth provider in Copy to clipboard
medusa-config.ts:
1234567891011121314151617181920module.exports = defineConfig({// …plugins: [{resolve: "@nualt/medusa-plugin-better-auth",options: {betterAuth: {baseURL: process.env.BETTER_AUTH_URL, // public URL of this serveremailAndPassword: { enabled: true },socialProviders: {google: {clientId: process.env.GOOGLE_CLIENT_ID!,clientSecret: process.env.GOOGLE_CLIENT_SECRET!,},},// Any Better Auth option or plugin works here (magic link,// passkey, 2FA, genericOAuth…). `database` and `basePath`// are managed by the plugin.},// autoLink: "verified-email" (default) | "never"
- Set the environment variables:
12BETTER_AUTH_SECRET=<openssl rand -hex 32>BETTER_AUTH_URL=https://api.your-store.com
- Create the Better Auth tables. In development the plugin migrates automatically at boot (Copy to clipboard
autoMigratedefaults to true outside production). For production, run the migration during your deploy, from the directory you start Medusa from:
1npx medusa-plugin-better-auth migrate
The command loads your Copy to clipboardmedusa-config, applies the Better Auth schema migrations to the configured database, and exits. It is idempotent, so running it on every deploy is safe.
The plugin requires Better Auth Copy to clipboard>= 1.5.0 (the migration API moved to Copy to clipboardbetter-auth/db/migration in 1.5.0); it is verified against 1.6.x.
Installing with npm
pnpm and yarn install this plugin without friction. npm's flat hoisting and strict peer resolution need two extra steps (verified on the official Copy to clipboardcreate-medusa-app monorepo template, Medusa 2.17):
- Peer conflict at install — better-auth ships an optional peer chain (Copy to clipboard
@lynx-js/react) that pins Copy to clipboard@types/react@^18, conflicting with the template's React 19. Install with:
1npm install @nualt/medusa-plugin-better-auth better-auth --legacy-peer-deps
- Copy to clipboard
joseversion clash at runtime — if an older Copy to clipboardjose(v4) ends up hoisted at your workspace root, the Better Auth OAuth module crashes with Copy to clipboardThe requested module 'jose' does not provide an export named 'customFetch'— and only once a social provider is configured, which makes it look like a provider bug. Fix: delete Copy to clipboardnode_modulesand Copy to clipboardpackage-lock.json, then reinstall (npm does not restructure a locked tree). Belt and braces, add to your root Copy to clipboardpackage.json:
1234"overrides": {"better-auth": { "jose": "^6.1.0" },"@better-auth/core": { "jose": "^6.1.0" }}
The plugin detects the Copy to clipboardjose crash at boot and prints this exact remedy. In an npm workspaces monorepo, install the plugin from the workspace root (hoisted): Medusa's module resolver looks the auth provider up from the root Copy to clipboardnode_modules.
Zero-config providers
Set a pair of environment variables and the provider is live — button and brand icon included, on both the storefront helpers and the admin login widget:
1socialProviders: socialProvidersFromEnv(),
Provider Environment variables Google Copy to clipboardGOOGLE_CLIENT_ID / Copy to clipboardGOOGLE_CLIENT_SECRET Apple Copy to clipboardAPPLE_CLIENT_ID / Copy to clipboardAPPLE_CLIENT_SECRET Facebook Copy to clipboardFACEBOOK_CLIENT_ID / Copy to clipboardFACEBOOK_CLIENT_SECRET Microsoft Copy to clipboardMICROSOFT_CLIENT_ID / Copy to clipboardMICROSOFT_CLIENT_SECRET Discord Copy to clipboardDISCORD_CLIENT_ID / Copy to clipboardDISCORD_CLIENT_SECRET TikTok Copy to clipboardTIKTOK_CLIENT_ID / Copy to clipboardTIKTOK_CLIENT_SECRET X (Twitter) Copy to clipboardTWITTER_CLIENT_ID / Copy to clipboardTWITTER_CLIENT_SECRET GitHub Copy to clipboardGITHUB_CLIENT_ID / Copy to clipboardGITHUB_CLIENT_SECRET
An incomplete pair (ID without SECRET) is ignored with an explicit boot warning. Providers outside this list still work through the regular passthrough — merge them in manually:
1socialProviders: { ...socialProvidersFromEnv(), zoom: { clientId, clientSecret } },
The UI helpers render a clean fallback (initial-letter badge) for any provider without a bundled brand icon.
Apple: Copy to clipboardAPPLE_CLIENT_SECRETis not a static secret but a signed JWT you generate from your Apple Developer Copy to clipboard.p8key (six-month max lifetime). Generate it, then treat it as a regular env var.
Better Auth plugins from your Medusa config
Copy to clipboardmedusa-config.ts compiles to CommonJS, but Copy to clipboardbetter-auth/plugins is ESM-only — a static import would crash at require time. Declare plugins lazily instead; the plugin resolves them with a dynamic import when the Better Auth instance is built:
1234567891011import { lazyBetterAuthPlugin } from "@nualt/medusa-plugin-better-auth/lib/lazy-plugins"betterAuth: {plugins: [lazyBetterAuthPlugin("magicLink", {sendMagicLink: async ({ email, url }) => {// send the email with your provider (Resend, SMTP…)},}),],}
The optional module specifier accepts an installed package name, an absolute path, or a Copy to clipboardfile: URL. Relative paths such as Copy to clipboard./my-plugin are rejected because they would resolve from the plugin's compiled directory rather than from your Medusa project. The default Copy to clipboardbetter-auth/plugins module used above needs no extra configuration.
Magic link (passwordless)
Full recipe: enable the plugin as above, send the email (or log the link in dev), and point Copy to clipboardcallbackURL at your storefront page with the Copy to clipboard?better-auth=1 marker — the standard session exchange handles the rest. See the working implementation in nualt-shop (Copy to clipboardapps/backend/medusa-config.ts and Copy to clipboardapps/storefront/src/modules/account/components/better-auth-login/).
Endpoints
Endpoint Purpose Copy to clipboardALL /better-auth/* Every Better Auth flow (sign-in, callbacks, magic links…) Copy to clipboardGET /better-auth/bridge/providers Configured methods (Copy to clipboardsocial, Copy to clipboardemail_password, Copy to clipboardmagic_link), for building login UIs Copy to clipboardPOST /better-auth/bridge/link/customer Link the session identity to an existing customer (idempotent) Copy to clipboardPOST /better-auth/bridge/link/user Link to an existing admin user (401 if none) Copy to clipboardPOST /auth/customer/better-auth Exchange the session for a Medusa customer token (core route) Copy to clipboardPOST /auth/user/better-auth Exchange for an admin token (core route)
Storefront recipe (Next.js)
12345678import { createAuthClient } from "better-auth/react"import { magicLinkClient } from "better-auth/client/plugins"export const authClient = createAuthClient({baseURL: `${BACKEND_URL}/better-auth`,fetchOptions: { credentials: "include" },plugins: [magicLinkClient()],})
Sign in with any Better Auth flow, then exchange the session:
- Copy to clipboard
POST /better-auth/bridge/link/customer - Copy to clipboard
POST /auth/customer/better-auth→ Copy to clipboard{ token } - First login only: Copy to clipboard
POST /store/customerswith the token, then Copy to clipboardPOST /auth/token/refresh - Use the final token like any Medusa customer JWT.
The storefront and backend must share a registrable domain (e.g. Copy to clipboardshop.example.com / Copy to clipboardapi.example.com) so the browser sends the Better Auth session cookie cross-origin. Exchange calls must run in the browser (Copy to clipboardcredentials: "include"), not from a server runtime. The storefront origin must also be included in Medusa's Copy to clipboardAUTH_CORS env var because the core exchange routes (Copy to clipboard/auth/customer/better-auth, Copy to clipboard/auth/token/refresh) live under Copy to clipboard/auth/* and are gated by that policy.
See a complete implementation in nualt-shop (Copy to clipboardapps/storefront/src/lib/better-auth/).
Ongoing hardening work and proposed follow-ups are recorded in the development tracker.
Admin dashboard
The plugin ships a Copy to clipboardlogin.before widget: social buttons appear above the password form automatically for every configured social provider. Linking rules: the email must be verified by the provider and belong to an existing invited admin user.
Options
Option Default Description Copy to clipboardbetterAuth — (required) Passthrough Better Auth config. Copy to clipboarddatabase and Copy to clipboardbasePath are managed by the plugin; core table names default to Copy to clipboardba_user, Copy to clipboardba_session, Copy to clipboardba_account, Copy to clipboardba_verification. Copy to clipboardautoLink Copy to clipboard"verified-email" Controls automatic identity linking for customers only: Copy to clipboard"verified-email" links when the provider verified the email; Copy to clipboard"never" never links automatically. Admin linking is always explicit — an invited admin user must exist and the provider must have verified the email; Copy to clipboardautoLink has no effect on the Copy to clipboardlink/user route. Copy to clipboardautoMigrate Copy to clipboardNODE_ENV !== "production" Run Better Auth schema migrations at boot. Copy to clipboardnormalizeCustomerEmails Copy to clipboardtrue Lowercase and trim new native Medusa customer/cart emails, resolve native Copy to clipboardemailpass logins case-insensitively, and reject registration when an active customer already exists with different casing.
Secret resolution: Copy to clipboardbetterAuth.secret, else Copy to clipboardBETTER_AUTH_SECRET. The server refuses to boot without one. Caveat: in setups where the config module isn't loaded at plugin import time (some pnpm monorepos), the failure surfaces as a logged startup error and errors on Copy to clipboard/better-auth/* instead of a hard boot refusal. A missing secret breaks email normalization too (it needs the resolved options); failures during Better Auth's own initialization after options resolved (e.g. the npm Copy to clipboardjose issue above) are scoped to Copy to clipboard/better-auth/* only — email normalization and native Medusa customer/cart/emailpass flows keep working. Trusted origins are derived from your Medusa Copy to clipboardauthCors/Copy to clipboardstoreCors/Copy to clipboardadminCors and merged with any Copy to clipboardbetterAuth.trustedOrigins you provide.
Customer email normalization and guest orders
Heads up — this touches core Medusa routes. With Copy to clipboardnormalizeCustomerEmailsenabled (the default), the plugin attaches middlewares to Copy to clipboardPOST /auth/customer/emailpass, Copy to clipboardPOST /auth/customer/emailpass/register, Copy to clipboardPOST /store/customersand the cart routes to canonicalize emails. This is the only place the plugin reaches outside Copy to clipboard/better-auth/*, and it exists because case-duplicate accounts are a real-world footgun. Set Copy to clipboardnormalizeCustomerEmails: falseto keep Medusa's native case-sensitive behavior untouched.
With Copy to clipboardnormalizeCustomerEmails enabled, the plugin applies one canonical email form to native Medusa customer registration, customer creation, and guest cart writes. Existing mixed-case Copy to clipboardemailpass identities remain usable: login first resolves the stored identity case-insensitively, then authenticates against its existing password hash.
An existing Copy to clipboardhas_account: true customer blocks another native registration with the same email under different casing. An existing guest customer (Copy to clipboardhas_account: false) does not: Medusa deliberately keeps guest and registered customer records separate.
The plugin never merges guest orders automatically based on an email string. After login, expose Medusa's order-transfer flow to let the customer request a transfer (Copy to clipboardPOST /store/orders/:id/transfer/request) and confirm it using the token sent to the order email. This proves mailbox ownership before order data is attached to the account.
Production checklist
The plugin stays out of the hot path — Better Auth is only exercised at sign-in, after which clients hold native Medusa tokens — but the following must be configured before going live. Everything below is standard Better Auth configuration passed through the Copy to clipboardbetterAuth option.
Rate limiting across instances. Better Auth enables its rate limiter in production, but the default storage is in-memory, i.e. per instance. Behind a load balancer, switch to shared storage and tighten the sensitive endpoints:
12345678910111213betterAuth: {rateLimit: {storage: "database", // or "secondary-storage" with RediscustomRules: {"/sign-in/email": { window: 10, max: 3 },"/sign-up/email": { window: 60, max: 5 },},},// Behind a proxy/CDN, tell Better Auth where the client IP lives:advanced: {ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },},}
Also rate-limit Copy to clipboard/auth/* at your reverse proxy: the Medusa core exchange routes (Copy to clipboard/auth/customer/better-auth, Copy to clipboard/auth/user/better-auth) are not covered by Better Auth's limiter and each call costs a session lookup.
Cross-subdomain cookies. With a storefront on Copy to clipboardshop.example.com and the API on Copy to clipboardapi.example.com, configure Copy to clipboardadvanced.crossSubDomainCookies = { enabled: true, domain: "example.com" } (and keep Copy to clipboarduseSecureCookies on). This is the most common source of "works locally, fails in production" reports.
Postgres connections. The plugin runs its own Copy to clipboardpg pool (default max 10 per instance) next to Medusa's. The plugin owns this database option, so it cannot currently be tuned through Copy to clipboardbetterAuth.database; size your database or pooler (pgbouncer) accordingly.
Session table growth. Expired Copy to clipboardba_session rows are not purged eagerly; schedule a periodic cleanup (Copy to clipboarddelete from ba_session where "expiresAt" < now()).
Migrations. Keep Copy to clipboardautoMigrate off in production and run Copy to clipboardnpx medusa-plugin-better-auth migrate during deploys.
License
MIT

