npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nualt/medusa-plugin-better-auth

v0.1.1

Published

Better Auth as the authentication engine for Medusa v2 — social OAuth, magic links, passkeys and 2FA for customers and admin users.

Readme

@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 /better-auth/*, with its tables (ba_*) in your Medusa Postgres. It handles every authentication flow. A Medusa auth module provider (better-auth) then bridges the result: it validates the Better Auth session and lets Medusa issue its own native tokens for the requested actor (customer or user). 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

pnpm add @nualt/medusa-plugin-better-auth better-auth
  1. Register the plugin and the auth provider in medusa-config.ts:
module.exports = defineConfig({
  // …
  plugins: [
    {
      resolve: "@nualt/medusa-plugin-better-auth",
      options: {
        betterAuth: {
          baseURL: process.env.BETTER_AUTH_URL, // public URL of this server
          emailAndPassword: { 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"
      },
    },
  ],
  modules: [
    {
      resolve: "@medusajs/medusa/auth",
      options: {
        providers: [
          { resolve: "@medusajs/medusa/auth-emailpass", id: "emailpass" },
          {
            resolve: "@nualt/medusa-plugin-better-auth/providers/better-auth",
            id: "better-auth",
          },
        ],
      },
    },
  ],
})
  1. Set the environment variables:
BETTER_AUTH_SECRET=<openssl rand -hex 32>
BETTER_AUTH_URL=https://api.your-store.com
  1. Create the Better Auth tables. In development the plugin migrates automatically at boot (autoMigrate defaults to true outside production). For production, run the migration during your deploy, from the directory you start Medusa from:
npx medusa-plugin-better-auth migrate

The command loads your medusa-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 >= 1.5.0 (the migration API moved to better-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 create-medusa-app monorepo template, Medusa 2.17):

  1. Peer conflict at install — better-auth ships an optional peer chain (@lynx-js/react) that pins @types/react@^18, conflicting with the template's React 19. Install with:

    npm install @nualt/medusa-plugin-better-auth better-auth --legacy-peer-deps
  2. jose version clash at runtime — if an older jose (v4) ends up hoisted at your workspace root, the Better Auth OAuth module crashes with The 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 node_modules and package-lock.json, then reinstall (npm does not restructure a locked tree). Belt and braces, add to your root package.json:

    "overrides": {
      "better-auth": { "jose": "^6.1.0" },
      "@better-auth/core": { "jose": "^6.1.0" }
    }

The plugin detects the jose 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 node_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:

socialProviders: socialProvidersFromEnv(),

| Provider | Environment variables | | --- | --- | | Google | GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | | Apple | APPLE_CLIENT_ID / APPLE_CLIENT_SECRET | | Facebook | FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | | Microsoft | MICROSOFT_CLIENT_ID / MICROSOFT_CLIENT_SECRET | | Discord | DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET | | TikTok | TIKTOK_CLIENT_ID / TIKTOK_CLIENT_SECRET | | X (Twitter) | TWITTER_CLIENT_ID / TWITTER_CLIENT_SECRET | | GitHub | GITHUB_CLIENT_ID / GITHUB_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:

socialProviders: { ...socialProvidersFromEnv(), zoom: { clientId, clientSecret } },

The UI helpers render a clean fallback (initial-letter badge) for any provider without a bundled brand icon.

Apple: APPLE_CLIENT_SECRET is not a static secret but a signed JWT you generate from your Apple Developer .p8 key (six-month max lifetime). Generate it, then treat it as a regular env var.

Better Auth plugins from your Medusa config

medusa-config.ts compiles to CommonJS, but better-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:

import { 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 file: URL. Relative paths such as ./my-plugin are rejected because they would resolve from the plugin's compiled directory rather than from your Medusa project. The default better-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 callbackURL at your storefront page with the ?better-auth=1 marker — the standard session exchange handles the rest. See the working implementation in nualt-shop (apps/backend/medusa-config.ts and apps/storefront/src/modules/account/components/better-auth-login/).

Endpoints

| Endpoint | Purpose | | --- | --- | | ALL /better-auth/* | Every Better Auth flow (sign-in, callbacks, magic links…) | | GET /better-auth/bridge/providers | Configured methods (social, email_password, magic_link), for building login UIs | | POST /better-auth/bridge/link/customer | Link the session identity to an existing customer (idempotent) | | POST /better-auth/bridge/link/user | Link to an existing admin user (401 if none) | | POST /auth/customer/better-auth | Exchange the session for a Medusa customer token (core route) | | POST /auth/user/better-auth | Exchange for an admin token (core route) |

Storefront recipe (Next.js)

import { 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:

  1. POST /better-auth/bridge/link/customer
  2. POST /auth/customer/better-auth{ token }
  3. First login only: POST /store/customers with the token, then POST /auth/token/refresh
  4. Use the final token like any Medusa customer JWT.

The storefront and backend must share a registrable domain (e.g. shop.example.com / api.example.com) so the browser sends the Better Auth session cookie cross-origin. Exchange calls must run in the browser (credentials: "include"), not from a server runtime. The storefront origin must also be included in Medusa's AUTH_CORS env var because the core exchange routes (/auth/customer/better-auth, /auth/token/refresh) live under /auth/* and are gated by that policy.

See a complete implementation in nualt-shop (apps/storefront/src/lib/better-auth/).

Ongoing hardening work and proposed follow-ups are recorded in the development tracker.

Admin dashboard

The plugin ships a login.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 | | --- | --- | --- | | betterAuth | — (required) | Passthrough Better Auth config. database and basePath are managed by the plugin; core table names default to ba_user, ba_session, ba_account, ba_verification. | | autoLink | "verified-email" | Controls automatic identity linking for customers only: "verified-email" links when the provider verified the email; "never" never links automatically. Admin linking is always explicit — an invited admin user must exist and the provider must have verified the email; autoLink has no effect on the link/user route. | | autoMigrate | NODE_ENV !== "production" | Run Better Auth schema migrations at boot. | | normalizeCustomerEmails | true | Lowercase and trim new native Medusa customer/cart emails, resolve native emailpass logins case-insensitively, and reject registration when an active customer already exists with different casing. |

Secret resolution: betterAuth.secret, else BETTER_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 /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 jose issue above) are scoped to /better-auth/* only — email normalization and native Medusa customer/cart/emailpass flows keep working. Trusted origins are derived from your Medusa authCors/storeCors/adminCors and merged with any betterAuth.trustedOrigins you provide.

Customer email normalization and guest orders

Heads up — this touches core Medusa routes. With normalizeCustomerEmails enabled (the default), the plugin attaches middlewares to POST /auth/customer/emailpass, POST /auth/customer/emailpass/register, POST /store/customers and the cart routes to canonicalize emails. This is the only place the plugin reaches outside /better-auth/*, and it exists because case-duplicate accounts are a real-world footgun. Set normalizeCustomerEmails: false to keep Medusa's native case-sensitive behavior untouched.

With normalizeCustomerEmails enabled, the plugin applies one canonical email form to native Medusa customer registration, customer creation, and guest cart writes. Existing mixed-case emailpass identities remain usable: login first resolves the stored identity case-insensitively, then authenticates against its existing password hash.

An existing has_account: true customer blocks another native registration with the same email under different casing. An existing guest customer (has_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 (POST /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 betterAuth 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:

betterAuth: {
  rateLimit: {
    storage: "database", // or "secondary-storage" with Redis
    customRules: {
      "/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 /auth/* at your reverse proxy: the Medusa core exchange routes (/auth/customer/better-auth, /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 shop.example.com and the API on api.example.com, configure advanced.crossSubDomainCookies = { enabled: true, domain: "example.com" } (and keep useSecureCookies on). This is the most common source of "works locally, fails in production" reports.

Postgres connections. The plugin runs its own pg pool (default max 10 per instance) next to Medusa's. The plugin owns this database option, so it cannot currently be tuned through betterAuth.database; size your database or pooler (pgbouncer) accordingly.

Session table growth. Expired ba_session rows are not purged eagerly; schedule a periodic cleanup (delete from ba_session where "expiresAt" < now()).

Migrations. Keep autoMigrate off in production and run npx medusa-plugin-better-auth migrate during deploys.

License

MIT