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

customer-registration

v0.0.124

Published

Medusa plugin that overrides store customer registration, enforces email/phone verification flags, and provides OTP management module.

Readme

Medusa Plugin: Customer Registration & OTP Verification

A comprehensive Medusa v2 plugin that provides OTP-based verification for email and phone functionality, with support for flexible registration identifiers, phone-only authentication, contact change flows, and account deletion.

Features

  • Unified OTP API: Single endpoints for sending and verifying OTPs
  • Token-based Verification: Secure JWT token system for OTP verification
  • Multiple Verification Types: Email verification and phone verification
  • Workflow-based Processing: Automatic handling of verification flags
  • Password Reset Support: Notification subscriber for Medusa's built-in password reset flow
  • Flexible Configuration: Per-purpose channel configuration (email/SMS)
  • Throttling & Rate Limiting: Built-in protection against OTP spam
  • Database Migrations: Automatic schema updates for verification columns
  • Account Deletion Request Flow: Two-step OTP flow to request and confirm account deletion with optional cancel flow
  • Unified registration & login: POST /auth/customer/emailpass/register creates an emailpass or phonepass identity (from JSON body + registration.identifier); POST /auth/customer/emailpass logs in with { email, password } or { phone, password } per login.identifier, or { email_or_phone, password } (values containing @ are treated as email; otherwise as phone)
  • Per-channel OTP at login: When registration.identifier is "both" and require_verification is on, email login only requires email verified; phone login only requires phone verified — you can use either method after completing that channel’s OTP
  • Authenticated Contact Change: Dedicated OTP-verified routes for updating phone or email — new value is embedded in the signed JWT, no metadata staging required

Quick Start

  1. Install the plugin:
npm install customer-registration
  1. Add to medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"

export default defineConfig({
  plugins: [
    {
      resolve: "customer-registration",
      options: {
        storefrontUrl: "http://localhost:8000",

        registration: {
          identifier: "phone", // "email" | "phone" | "both"
          require_verification: true,
        },
        login: {
          identifier: "phone", // defaults to registration.identifier
        },

        password_reset: {
          // With `login.identifier: "phone"`, set `sms_body` (and `storefrontUrl`). With `"email"`, set `template`. With `"both"`, set both.
          sms_body: "Reset your password: {{reset_url}}",
        },

        account_deletion_request: {
          template: "src/templates/emails/account-deletion-request.html",
          subject: "Confirm your account deletion request",
          scheduled_days: 7,
        },
        account_deletion_cancel: {
          template: "src/templates/emails/account-deletion-cancel.html",
          subject: "Confirm cancellation of account deletion",
        },

        email_verification: {
          channel: "email",
          subject: "Verify your email",
        },
        phone_verification: {
          channel: "sms",
        },
      },
    },
  ],
})
  1. Run migrations:
npx medusa db:migrate
  1. Use the API — see API Endpoints below.

Installation

Local Development

  1. Publish the plugin to local registry:
cd plugins/customer-registration
npx medusa plugin:publish
  1. Install in your Medusa application:
cd ../../test-medusa
npx medusa plugin:add customer-registration
  1. Register the plugin in medusa-config.ts (see Quick Start above).

  2. Start development mode (in plugin directory):

cd plugins/customer-registration
npx medusa plugin:develop
  1. Start your Medusa application:
cd ../../test-medusa
yarn dev

Configuration

registration.identifier

Controls which field is required at sign-up:

| Value | Behaviour | |---|---| | "email" (default) | Email required; standard Medusa registration | | "phone" | Phone required; no email needed; uses phonepass provider | | "both" | Both email and phone required |

login.identifier

Controls which auth providers are accepted at login. Defaults to registration.identifier.

| Value | Accepted login methods | |---|---| | "email" | POST /auth/customer/emailpass with { "email", "password" } only | | "phone" | Same endpoint with { "phone", "password" } only | | "both" | Same endpoint; either email or phone (not both) plus password in the JSON body |

You may send email_or_phone instead of email or phone (same endpoint): if the value contains @, it is used as email; otherwise as phone. Do not send email_or_phone together with email or phone.

Login always uses POST /auth/customer/emailpass (single URL). There is no /auth/customer/phonepass route; phone registration uses the same POST /auth/customer/emailpass/register as email registration.

Credentials are normally sent in the JSON body; the same keys (email, phone, email_or_phone, password) can be supplied on the query string for missing fields (see buildUnifiedLoginAuthData) — prefer body for passwords.

require_verification and login

When registration.require_verification is true (default), login checks OTP flags for the credential you use (see enforce-registration-verification.ts):

  • registration.identifier: "email" — signing in with email requires email_verified.
  • registration.identifier: "phone" — signing in with phone requires phone_verified.
  • registration.identifier: "both" — signing in with email requires email_verified only (phone may still be unverified). Signing in with phone requires phone_verified only (email may still be unverified).

So with both, a customer can log in with email after email OTP even if phone OTP is still pending, and the reverse for phone login.

When require_verification is false, these checks are skipped.

Full options reference

{
  resolve: "customer-registration",
  options: {
    storefrontUrl?: string,          // Base URL for password reset links

    registration?: {
      identifier?: "email" | "phone" | "both",  // default: "email"
      require_verification?: boolean,            // default: true
    },
    login?: {
      identifier?: "email" | "phone" | "both",  // default: registration.identifier
    },

    // OTP settings (apply to all types)
    otpLength?: number,              // default: 6
    otpCharset?: "numeric" | "alphanumeric",  // default: "numeric"
    otpExpiryMinutes?: number,       // default: 15
    maxAttempts?: number,            // default: 5

    email_verification?: {
      channel: string,               // e.g. "email"
      template?: string,
      subject?: string,
      resendThrottleSeconds?: number,
      autoSendOnRegistration?: boolean,
    },
    phone_verification?: {
      channel: string,               // e.g. "sms"
      template?: string,
      resendThrottleSeconds?: number,
    },

    password_reset?: {
      template?: string,             // HTML email path; required if login.identifier is "email" or "both"
      subject?: string,
      sms_body?: string,             // SMS text; supports {{token}}, {{reset_url}}, {{phone}} — required if login is "phone" or "both"
    },
    account_deletion_request?: {
      template: string,
      subject?: string,
      scheduled_days?: number,       // default: 7
    },
    account_deletion_cancel?: {
      template: string,
      subject?: string,
    },
  },
}

API Endpoints

Registration & Initial Verification

| Endpoint | Method | Auth | Description | |---|---|---|---| | /store/customers | POST | Bearer (pre-customer) | Create customer account | | /store/customers/otp/send | POST | — | Send OTP for initial email/phone verification | | /store/customers/otp/verify | POST | — | Verify OTP code; returns login token on success |

Send OTP

POST /store/customers/otp/send
{
  "customer_id": "cus_...",
  "type": "phone_verification"  # or "email_verification"
}
# Response: { "token": "...", "expires_at": "..." }

Verify OTP

POST /store/customers/otp/verify
{
  "token": "<token from send>",
  "code": "482917"
}
# Response: { "verified": true, "customer": {...}, "token": "<login jwt>", "needs_login": false }

Login & registration (customer auth)

Implementation lives under src/api/auth/customer/: emailpass/ (login, register, password reset), shared/ (credential parsing, verification, JWT helpers).

| Endpoint | Method | Description | |---|---|---| | /auth/customer/emailpass/register | POST | Register. JSON: password plus identifier per registration.identifieremail only for "email" and "both" (for "both", phone is added on POST /store/customers); phone only for "phone". Dispatches to register("emailpass") or register("phonepass"). Response: { "token" } (pre-customer JWT). See emailpass/register/route.ts, parse-register-body.ts, complete-registration-token.ts. | | /auth/customer/emailpass | POST | Login. JSON: password plus exactly one of email or phone (XOR), or email_or_phone alone (expanded to email vs phone by @). Optional query params can fill missing keys (body wins). emailpass/route.tshandleCustomerLogin. |

Password reset supports email and/or SMS by login.identifier; see Password Reset.


Contact Change (Phone or Email Update)

These are the dedicated authenticated routes for changing a customer's phone or email. PATCH /store/customers/me rejects phone and email fields — all contact changes must go through these endpoints.

| Endpoint | Method | Auth | Description | |---|---|---|---| | /store/customers/me/contact | POST | Customer JWT | Request contact change — validates new value, sends OTP, returns token | | /store/customers/me/contact/verify | POST | Customer JWT | Verify OTP and apply the change |

How it works

The new phone or email is embedded directly in the signed OTP JWT. No metadata is written to the customer record during the pending state. On successful verification:

  1. customer.phone / customer.email is updated
  2. phone_verified / email_verified is set to true
  3. provider_identity.entity_id is updated for phonepass / emailpass only when the changed field is the active login.identifier — so login continues to work with the new value

Step 1 — Request the change

curl -X POST http://localhost:9000/store/customers/me/contact \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CUSTOMER_JWT" \
  -d '{ "phone": "+15559998888" }'

# Response
{ "token": "<otp_token>", "expires_at": "2026-03-12T10:15:00.000Z" }

Only one field at a time is accepted. Sending both phone and email returns a 400.

Step 2 — Verify the OTP

curl -X POST http://localhost:9000/store/customers/me/contact/verify \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CUSTOMER_JWT" \
  -d '{
    "token": "<otp_token from step 1>",
    "code": "482917"
  }'

# Response
{
  "customer": { "id": "cus_...", "phone": "+15559998888", ... },
  "token": "<fresh_login_jwt>"
}

The response includes a fresh login JWT so the caller is immediately re-authenticated after the change.

Security notes

  • The OTP token is signed with the server's jwtSecret — the new value cannot be tampered with
  • The verify route checks that token.customer_id === auth_context.actor_id, preventing one customer from applying another's OTP
  • provider_identity.entity_id sync has a compensation function: if a later workflow step fails the entity_id is rolled back to the original value
  • If the current phone/email is null (first-time contact assignment), the flow works identically — the field is set from null to the new value and marked verified

What PATCH /store/customers/me does now

Non-contact fields (e.g. first_name, last_name) continue to work as before. Passing phone or email in the body returns:

{
  "type": "not_allowed",
  "message": "Phone changes require OTP verification. Use POST /store/customers/me/contact to request a change."
}

Profile Update

| Endpoint | Method | Auth | Description | |---|---|---|---| | /store/customers/me | PATCH | Customer JWT | Update non-contact profile fields (first_name, last_name, etc.) |


Password Reset

Allowed JSON fields follow login.identifier (same idea as login): email, phone, email_or_phone, or identifier (email alias). Exactly one lookup field; do not combine with email_or_phone and email/phone at once.

| login.identifier | Request reset with | |---|---| | email | email, identifier, or email_or_phone containing @ | | phone | phone or email_or_phone without @ | | both | Any of the above |

  • Email path: sends HTML email using password_reset.template.
  • Phone path: sends SMS using password_reset.sms_body (placeholders: {{token}}, {{reset_url}}, {{phone}}).
  • Config: storefrontUrl is required when password_reset is set. If login is email, template is required; if phone, sms_body is required; if both, both template and sms_body are required (validated at plugin load).

| Endpoint | Method | Description | |---|---|---| | /auth/customer/emailpass/reset-password | POST | Request reset (email and/or SMS per channel) | | /auth/customer/emailpass/update | POST | Set new password; Authorization: Bearer <token> + same lookup field shape as request + password |

# Request reset (email example)
POST /auth/customer/emailpass/reset-password
{ "identifier": "[email protected]" }

# Request reset (phone example)
POST /auth/customer/emailpass/reset-password
{ "phone": "+15551234567" }

# Complete reset (use Bearer token from link; body must include matching email or phone)
POST /auth/customer/emailpass/update
Authorization: Bearer <reset_token>
{ "email": "[email protected]", "password": "NewPassword123!" }

Account Deletion

| Endpoint | Method | Auth | Description | |---|---|---|---| | /store/customers/account-deletion/request | POST | Customer JWT | Request deletion — sends OTP, returns token | | /store/customers/account-deletion/confirm | POST | — | Confirm deletion with OTP code | | /store/customers/account-deletion/cancel-request | POST | — | Request cancellation (lookup by email/phone) | | /store/customers/account-deletion/cancel-confirm | POST | — | Confirm cancellation with OTP code | | /admin/account-deletion-requests | GET | Admin JWT | List deletion requests |

# 1. Request deletion
curl -X POST http://localhost:9000/store/customers/account-deletion/request \
  -H "Authorization: Bearer $CUSTOMER_JWT" \
  -d '{"reason": "No longer need account"}'
# Response: { "token": "...", "expires_at": "..." }

# 2. Confirm deletion
curl -X POST http://localhost:9000/store/customers/account-deletion/confirm \
  -d '{"token": "...", "code": "123456"}'

# 3. Request cancel (no auth; email lookup)
curl -X POST http://localhost:9000/store/customers/account-deletion/cancel-request \
  -d '{"email": "[email protected]"}'

# 4. Confirm cancel
curl -X POST http://localhost:9000/store/customers/account-deletion/cancel-confirm \
  -d '{"token": "...", "code": "123456"}'

Phone-only Registration & Login (phonepass)

When registration.identifier = "phone", customers register and log in with phone + password only. No email address is required.

1. Register the provider in medusa-config.ts

import { Modules } from "@medusajs/framework/utils"

export default defineConfig({
  modules: [
    {
      resolve: "@medusajs/medusa/auth",
      options: {
        providers: [
          {
            resolve: "customer-registration/providers/phonepass",
            id: "phonepass",
          },
        ],
      },
    },
  ],
  plugins: [
    {
      resolve: "customer-registration",
      options: {
        registration: {
          identifier: "phone",
          require_verification: true,
        },
        login: {
          identifier: "phone",
        },
        phone_verification: {
          channel: "sms",
        },
      },
    },
  ],
})

2. Registration flow

# Step 1 — create phonepass auth identity (unified register URL)
POST /auth/customer/emailpass/register
{ "phone": "+15551234567", "password": "SecretPass1!" }
# Response: { "token": "<pre-customer jwt>" }

# Step 2 — create customer record
POST /store/customers
Authorization: Bearer <token>
{ "phone": "+15551234567", "first_name": "Jane" }

# Step 3 — send phone OTP
POST /store/customers/otp/send
{ "customer_id": "cus_...", "type": "phone_verification" }
# Response: { "token": "<otp_token>", "expires_at": "..." }

# Step 4 — verify phone OTP (returns login token)
POST /store/customers/otp/verify
{ "token": "<otp_token>", "code": "482917" }
# Response: { "verified": true, "phone_verified": true, "token": "<login jwt>" }

3. Login flow

POST /auth/customer/emailpass
{ "phone": "+15551234567", "password": "SecretPass1!" }
# Response: { "token": "<jwt>" }

3b. Password reset (phone)

Configure password_reset.sms_body, storefrontUrl, and the notification module for SMS. Then:

POST /auth/customer/emailpass/reset-password
{ "phone": "+15551234567" }

POST /auth/customer/emailpass/update
Authorization: Bearer <token from SMS link>
{ "phone": "+15551234567", "password": "NewSecretPass1!" }

4. Update phone (after registration)

# Request change
POST /store/customers/me/contact
Authorization: Bearer <login jwt>
{ "phone": "+15559998888" }
# Response: { "token": "<otp_token>", "expires_at": "..." }

# Verify and apply
POST /store/customers/me/contact/verify
Authorization: Bearer <login jwt>
{ "token": "<otp_token>", "code": "739201" }
# Response: { "customer": { "phone": "+15559998888", ... }, "token": "<fresh jwt>" }

Modules

The plugin includes three modules:

  1. otp-verification — OTP generation, verification, and management
  2. customer-registration — Customer registration logic and route overrides
  3. account-deletion-request — Account deletion request lifecycle (pending → confirmed → completed/cancelled)

Workflows

| Workflow | Description | |---|---| | verify-email | Sets email_verified = true on the customer record | | verify-phone | Sets phone_verified = true on the customer record | | update-contact | Updates customer.phone / customer.email, sets verified flag, syncs provider_identity.entity_id when applicable | | send-otp | Resolves channel config, generates OTP, sends notification | | send-contact-change-otp | Generates OTP with new_value embedded in JWT, sends to the new contact address | | change-password | Updates customer password via the auth module |

Database Migrations

| Migration | Description | |---|---| | Migration20250120000000AddCustomerVerificationColumns | Adds email_verified and phone_verified columns to the customer table | | Migration20250118001000CreateOtpVerificationTable | Creates otp_verification table | | Migration20250221000000CreateAccountDeletionRequestTable | Creates account_deletion_request table | | Migration20250221000000AddAccountDeletionOtpPurposes | Extends otp_verification.purpose enum for account deletion flows |

npx medusa db:migrate

Password Reset (Helper Functions)

import {
  requestPasswordReset,
  completePasswordReset,
} from "customer-registration/helpers"

await requestPasswordReset(
  { email: "[email protected]" },
  { baseUrl: "https://store.example.com", publishableApiKey: "pk_..." }
)

await requestPasswordReset(
  { phone: "+15551234567" },
  { baseUrl: "https://store.example.com", publishableApiKey: "pk_..." }
)

await completePasswordReset(
  {
    email: "[email protected]",
    password: "NewPassword123!",
    token: "reset_token_from_email",
  },
  { baseUrl: "https://store.example.com", publishableApiKey: "pk_..." }
)

Requirements

  • Medusa v2.11.2 or higher
  • Node.js >= 20
  • Notification module configured with at least one provider (email/SMS)
  • Database migrations applied (npx medusa db:migrate)
  • phonepass auth provider registered in medusa-config (required when customers can register or log in with phone — i.e. registration.identifier and/or login.identifier is "phone" or "both")

Documentation

This README is the main overview: configuration, auth routes, OTP behaviour, and API tables.

Development

# Build
npm run build

# Watch mode
npx medusa plugin:develop

License

MIT