customer-registration
v0.0.148
Published
Medusa plugin that overrides store customer registration, enforces email/phone verification flags, and provides OTP management module.
Maintainers
Readme
customer-registration
Medusa v2 plugin that extends customer auth with OTP verification, phone login (
phonepass), verified contact updates, referral linking, and account deletion workflows.
Plugin Overview
This plugin customizes Medusa customer authentication and profile flows with:
- OTP verification for email/phone with configurable channels (email/SMS)
- admin OTP send/verify so POS staff can register a customer after one verified channel
- phone numbers stored as E.164 country code + number (
PHONE_REGEX:/^\+[1-9]\d{7,14}$/; Indian 10-digit input becomes+91XXXXXXXXXX) - phone OTP upgrades an existing guest on the same customer id instead of creating a duplicate
- email OTP upgrades an existing guest on the same customer id instead of creating a duplicate
- Google (and other social) sign-in upgrades an existing guest on the same customer id instead of creating a duplicate
- a mobile number can be linked to only one registered customer account (Account Section add/verify and registration)
- unified register/login routes for email and phone credentials
- passwordless email OTP login/register (
emailotp) alongside email+password - passwordless phone OTP login/register (
phoneotp) alongside phone+password - secure contact change flow (
/store/customers/me/contact) with OTP verification - account deletion request/cancel flows with OTP and scheduled deletion job
- referral linkage at signup plus a customer-facing
my-referralsendpoint - password reset support for both email and phone channels
Problem it solves
It adds production-oriented identity controls that are not available in default customer auth flows:
- enforce verified contact channels before login
- allow phone-based auth (
phonepass) in Medusa v2 - avoid direct email/phone mutation without OTP proof
- formalize account deletion lifecycle with cancellation and delayed execution
Medusa version
Built for Medusa v2 (@medusajs/framework, @medusajs/medusa, @medusajs/cli pinned to 2.11.2).
Installation & Setup
Install package
npm install customer-registrationor
yarn add customer-registrationRegister plugin in medusa-config.ts
import { defineConfig, Modules } from "@medusajs/framework/utils"
export default defineConfig({
modules: [
{
resolve: "@medusajs/medusa/auth",
options: {
providers: [
{
resolve: "customer-registration/providers/phonepass",
id: "phonepass",
},
{
resolve: "customer-registration/providers/emailotp",
id: "emailotp",
},
{
resolve: "customer-registration/providers/phoneotp",
id: "phoneotp",
},
],
},
},
],
plugins: [
{
resolve: "customer-registration",
options: {
storefrontUrl: "http://localhost:8000",
auth: {
email: { methods: ["password", "otp"] },
phone: { methods: ["password", "otp"] },
},
registration: { identifier: "both", require_verification: true },
login: { identifier: "both" },
email_auth: {
channel: "email",
template: "src/templates/emails/otp-verification-default.html",
subject: "Your login code",
resendThrottleSeconds: 90,
},
phone_auth: { channel: "sms", resendThrottleSeconds: 90 },
email_verification: { channel: "email", subject: "Verify your email" },
phone_verification: { channel: "sms" },
password_reset: {
template: "src/templates/emails/reset-password.html",
subject: "Reset Your Password",
sms_body: "Reset 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",
},
},
},
],
})Run migrations
npx medusa db:migrateConfiguration
Plugin options (options)
| Option | Type | Required | Default | Description |
|---|---|---:|---|---|
| storefrontUrl | string | No | undefined | Base storefront URL used for password reset link generation. Required when password_reset is configured. |
| registration.identifier | "email" \| "phone" \| "both" | No | "email" | Which identifier is used at registration. |
| registration.require_verification | boolean | No | true | Whether login requires verification flags (password login only; OTP login proves ownership). |
| auth.email.methods | Array<"password" \| "otp"> | No | ["password"] | Storefront auth methods. Add "otp" to enable POST /store/customers/otp/send with type: "email_auth". |
| auth.phone.methods | Array<"password" \| "otp"> | No | ["password"] | Storefront auth methods. Add "otp" to enable POST /store/customers/otp/send with type: "phone_auth". |
| login.identifier | "email" \| "phone" \| "both" | No | registration.identifier | Which login method(s) are accepted at /auth/customer/emailpass. |
| email_auth.channel | string | Yes when OTP enabled | none | Notification channel for unified email OTP login/register. |
| email_auth.template | string \| null | No | plugin default | HTML template for login code emails. |
| email_auth.subject | string | No | "Your login code" | Subject for login code emails. |
| email_auth.resendThrottleSeconds | number | No | 90 | Resend throttle for email OTP auth. |
| phone_auth.channel | string | Yes when phone OTP enabled | none | Notification channel for unified phone OTP login/register (typically sms). |
| phone_auth.template | string \| null | No | plugin default | SMS template for login codes. |
| phone_auth.resendThrottleSeconds | number | No | 90 | Resend throttle for phone OTP auth. |
| password_reset.template | string | Conditional | undefined | Email HTML template path for reset email. Required if login channel includes email. |
| password_reset.subject | string | No | "Reset Your Password" | Password reset email subject. |
| password_reset.sms_body | string | Conditional | undefined | SMS template body for reset flow; supports {{token}}, {{reset_url}}, {{phone}}. Required if login channel includes phone. |
| account_deletion_request.template | string | Yes when section used | none | Template for account deletion request OTP notification. |
| account_deletion_request.subject | string | No | "Confirm your account deletion request" | Subject for request OTP notification. |
| account_deletion_request.scheduled_days | number | No | 7 | Days from confirmation until deletion is due. |
| account_deletion_cancel.template | string | Yes when section used | none | Template for cancellation OTP notification. |
| account_deletion_cancel.subject | string | No | "Confirm cancellation of account deletion" | Subject for cancellation OTP notification. |
OTP module options (supported by OTP service)
⚠️ Note: these options are implemented in
OtpVerificationService/OtpConfig, but are not fully declared inCustomerRegistrationPluginOptionstype. Confirm your expected typing strategy before publishing.
| Option | Type | Required | Default | Description |
|---|---|---:|---|---|
| otpLength | number | No | 6 | OTP length. |
| otpCharset | "numeric" \| "alphanumeric" | No | "numeric" | OTP charset. |
| otpExpiryMinutes | number | No | 15 | OTP expiry in minutes. |
| maxAttempts | number | No | 5 | Max verify attempts before lockout. |
| email_verification.channel | string | Yes for email OTP | none | Notification channel (usually email). |
| email_verification.template | string \| null | No | null | Template path. |
| email_verification.subject | string | No | provider default | Subject for email. |
| email_verification.resendThrottleSeconds | number | No | 90 | Resend throttle. |
| email_verification.autoSendOnRegistration | boolean | No | undefined | Auto-send toggle (if used by caller flow). |
| phone_verification.channel | string | Yes for phone OTP | none | Notification channel (usually sms). |
| phone_verification.template | string \| null | No | null | SMS template path. |
| phone_verification.resendThrottleSeconds | number | No | 90 | Resend throttle. |
| account_deletion_request.channel | string | Yes for strict validation | fallback email | Channel for request OTP notifications. |
| account_deletion_request.template | string \| null | Yes | none | Template path. |
| account_deletion_request.subject | string | No | provider default | Subject. |
| account_deletion_request.resendThrottleSeconds | number | No | 90 | Resend throttle. |
| account_deletion_cancel.channel | string | Yes for strict validation | fallback email | Channel for cancellation OTP notifications. |
| account_deletion_cancel.template | string \| null | Yes | none | Template path. |
| account_deletion_cancel.subject | string | No | provider default | Subject. |
| account_deletion_cancel.resendThrottleSeconds | number | No | 90 | Resend throttle. |
Complete config example
{
resolve: "customer-registration",
options: {
storefrontUrl: "https://store.example.com",
registration: {
identifier: "both",
require_verification: true,
},
login: {
identifier: "both",
},
otpLength: 6,
otpCharset: "numeric",
otpExpiryMinutes: 15,
maxAttempts: 5,
email_verification: {
channel: "email",
template: "src/templates/emails/otp-verification.html",
subject: "Verify your email",
resendThrottleSeconds: 90,
},
phone_verification: {
channel: "sms",
template: "src/templates/sms/otp.txt",
resendThrottleSeconds: 90,
},
password_reset: {
template: "src/templates/emails/reset-password.html",
subject: "Reset Your Password",
sms_body: "Reset password: {{reset_url}}",
},
account_deletion_request: {
channel: "email",
template: "src/templates/emails/account-deletion-request.html",
subject: "Confirm your account deletion request",
scheduled_days: 7,
},
account_deletion_cancel: {
channel: "email",
template: "src/templates/emails/account-deletion-cancel.html",
subject: "Confirm cancellation",
},
},
}Environment Variables
Only one direct environment variable usage exists in plugin source:
| Variable | Required | Example | Purpose |
|---|---:|---|---|
| NODE_ENV | No | production | Used for development-only OTP config debug logging in OTP service. |
⚠️ Note: JWT secret/expiry are read from Medusa config module (
projectConfig.http.jwtSecret/jwtExpiresIn), not directly fromprocess.envin this plugin.
REST APIs / Routes
Auth routes
POST /auth/customer/emailpass/register
- Auth: Public
- Description: Registers customer identity through
emailpassorphonepassbased onregistration.identifier.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| password | string | Yes | Required always. |
| email | string | Conditional | Required when mode resolves to email. |
| phone | string | Conditional | Required when mode resolves to phone. |
Response:
{ "token": "..." }curl -X POST http://localhost:9000/auth/customer/emailpass/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"Secret123!"}'POST /auth/customer/emailpass
- Auth: Public
- Description: Unified login endpoint for email/phone credentials.
Body/query params:
| Field | Type | Required | Notes |
|---|---|---:|---|
| password | string | Yes | Required. |
| email | string | XOR | Use with password. |
| phone | string | XOR | Use with password. |
| email_or_phone | string | Optional alternative | Must not be combined with email/phone; value with @ becomes email. |
Response:
{ "token": "..." }POST /auth/customer/emailpass/reset-password
- Auth: Public
- Description: Requests password reset for email or phone channel depending on config.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| identifier | string | Conditional | Email alias field. |
| email | string | Conditional | Email lookup. |
| phone | string | Conditional | Phone lookup. |
| email_or_phone | string | Conditional | Auto-resolves by @. |
Response:
{}POST /auth/customer/emailpass/update
- Auth: Public with reset token
- Description: Completes password reset via
Authorization: Bearer <token>.
Body:
| Field | Type | Required |
|---|---|---:|
| password | string | Yes |
| identifier or email or phone or email_or_phone | string | Yes (exactly one logical lookup) |
Response:
{ "message": "Password reset successfully", "success": true }Storefront flows
- Password (unchanged):
POST /auth/customer/emailpass/register→POST /store/customers→ optionalPOST /store/customers/otp/send+verifywithtype: "email_verification". - Email OTP (unified):
POST /store/customers/otp/send(type: "email_auth") →POST /store/customers/otp/verify→ customer is logged in immediately (creates account with email only). - Phone OTP (unified):
POST /store/customers/otp/send(type: "phone_auth") →POST /store/customers/otp/verify→ logged in immediately. If a guest already exists with that canonical phone (+91XXXXXXXXXX), that row is upgraded (has_account,phone_verified,phoneotplogin). A new phone-only customer is created only when no row has that phone. - Both methods: Show a toggle such as “Continue with password” vs “Continue with email/SMS code” when
auth.email.methodsorauth.phone.methodsincludes"otp".
OTP auth vs password registration:
registration.identifier: "both"still requires email and phone on password registration (POST /auth/customer/emailpass/register→POST /store/customers). OTP login/register is channel-scoped: whichever channel the user verifies (email_authorphone_auth) is the only identifier required to create the account.
Breaking change:
/auth/customer/emailotp/*and/auth/customer/phoneotp/*were removed. Use/store/customers/otp/*withtype: "email_auth"ortype: "phone_auth"instead.
For dual OTP stores use registration.identifier: "both", login.identifier: "both", auth.email.methods / auth.phone.methods including "otp", plus email_auth and phone_auth channel config.
Cross-channel OTP login: When
login.identifieris"both", a customer who registered with password on one channel (e.g. email) and added the other identifier on their profile can OTP-login on that channel without completing verification OTP first. The plugin auto-attachesphoneotporemailotpto their existing auth identity on first OTP login.
Store routes
POST /store/customers
- Auth: Pre-customer auth token (registration flow)
- Description: Overrides default customer creation; supports
phoneand referral linking.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| email | string | Conditional | Depends on registration.identifier. |
| phone | string | Conditional | Depends on registration.identifier. |
| first_name | string | No | |
| last_name | string | No | |
| company_name | string | No | |
| metadata | object | No | metadata.referral_code is consumed then removed. |
| referral_code | string | No | Referrer customer id. |
Response:
{ "customer": { "id": "cus_...", "...": "..." } }GET /store/customers/me
- Auth: Customer JWT
- Description: Returns customer with appended
email_verifiedandphone_verified.
PATCH /store/customers/me
- Auth: Customer JWT
- Description: Updates non-contact profile fields only.
- Rejects:
email,phonewithNOT_ALLOWED.
POST /store/customers/me/contact
- Auth: Customer JWT
- Description: Starts contact change OTP flow. Phone is rejected if it is already linked to another registered customer (
has_account = true), already verified on another customer, or already used as a login credential — including unnormalized stored forms (7265033341vs+917265033341). Guest rows may still share an unverified phone for OTP upgrade.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| email | string | XOR | Exactly one of email/phone. |
| phone | string | XOR | Exactly one of email/phone. Stored as +[country code][number] (/^\+[1-9]\d{7,14}$/). 10-digit Indian mobiles are accepted and saved as +91XXXXXXXXXX. |
Response:
{ "token": "...", "expires_at": "..." }POST /store/customers/me/contact/verify
- Auth: Customer JWT
- Description: Verifies OTP and atomically updates contact + verification flags. Re-checks phone uniqueness before write so a second account cannot verify a number that became linked after OTP send.
Body:
| Field | Type | Required |
|---|---|---:|
| token | string | Yes |
| code | string | Yes |
Response:
{ "customer": { "id": "cus_..." }, "token": "..." }POST /store/customers/otp/send
- Auth: Public (rate-limited: 5 requests/minute per IP)
- Description: Unified OTP send for verification and passwordless login/register.
Verification (email_verification / phone_verification):
| Field | Type | Required |
|---|---|---:|
| customer_id | string | Yes |
| type | "email_verification" \| "phone_verification" | Yes |
curl -X POST http://localhost:9000/store/customers/otp/send \
-H "Content-Type: application/json" \
-d '{"customer_id":"cus_...","type":"email_verification"}'Email login/register (email_auth, requires auth.email.methods includes "otp"):
| Field | Type | Required |
|---|---|---:|
| email | string | Yes |
| type | "email_auth" | Yes |
curl -X POST http://localhost:9000/store/customers/otp/send \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","type":"email_auth"}'Phone login/register (phone_auth, requires auth.phone.methods includes "otp"):
| Field | Type | Required |
|---|---|---:|
| phone | string | Yes |
| type | "phone_auth" | Yes |
Response (verification):
{ "token": "...", "expires_at": "..." }Response (auth types — adds is_new_user):
{
"token": "...",
"expires_at": "2025-06-03T12:00:00.000Z",
"is_new_user": true
}POST /store/customers/otp/verify
- Auth: Public
- Description: Verifies OTP for verification flows or passwordless login/register (based on token purpose).
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| token | string | Yes | From send response. |
| code | string | Yes | OTP from email/SMS. |
| first_name | string | No | Auth types only — used when creating a new customer. |
| last_name | string | No | Auth types only. |
| referral_code | string | No | Auth types only — referrer customer id. |
curl -X POST http://localhost:9000/store/customers/otp/verify \
-H "Content-Type: application/json" \
-d '{"token":"OTP_TOKEN","code":"123456","first_name":"Alice"}'Response (verification):
{
"verified": true,
"customer": {},
"email_verified": true,
"token": null,
"needs_login": true
}Response (auth types):
{
"verified": true,
"customer": { "id": "cus_...", "email": "..." },
"is_new_user": false,
"token": "...",
"needs_login": false
}POST /store/customers/change-password
- Auth: Customer JWT
- Description: Changes password using old/new/confirm values.
Body:
| Field | Type | Required |
|---|---|---:|
| old_password | string | Yes |
| new_password | string | Yes |
| confirm_password | string | Yes |
Response:
{ "message": "Password changed successfully", "customer_id": "cus_..." }POST /store/customers/account-deletion/request
- Auth: Customer JWT
- Description: Sends OTP for deletion confirmation.
Body:
| Field | Type | Required |
|---|---|---:|
| reason | string \| null | No |
Response:
{ "token": "...", "expires_at": "..." }POST /store/customers/account-deletion/confirm
- Auth: Public
- Description: Confirms deletion request via OTP; creates confirmed request row.
Body:
| Field | Type | Required |
|---|---|---:|
| token | string | Yes |
| code | string | Yes |
Response:
{
"request": {
"id": "adr_...",
"customer_id": "cus_...",
"reason": null,
"deletion_scheduled_at": "...",
"status": "confirmed"
}
}POST /store/customers/account-deletion/cancel-request
- Auth: Public (IP rate-limited)
- Description: Sends OTP for canceling an active deletion request.
Body:
| Field | Type | Required |
|---|---|---:|
| email | string | Conditional |
| phone | string | Conditional |
Response:
{ "token": "...", "expires_at": "..." }POST /store/customers/account-deletion/cancel-confirm
- Auth: Public
- Description: Confirms cancellation OTP and marks request canceled.
Body:
| Field | Type | Required |
|---|---|---:|
| token | string | Yes |
| code | string | Yes |
Response:
{
"cancelled": true,
"request": {
"id": "adr_...",
"customer_id": "cus_...",
"status": "cancelled",
"cancelled_at": "..."
}
}GET /store/my-referrals
- Auth: Customer JWT
- Description: Returns customers referred by the authenticated customer.
Response:
{
"children": [
{
"referral_link_id": "refl_...",
"referred_at": "...",
"parent_customers_by_level": { "1": "cus_referrer" },
"customer": {
"id": "cus_child",
"email": "[email protected]",
"first_name": null,
"last_name": null,
"phone": null,
"created_at": "..."
}
}
]
}Admin routes
POS calls these from another origin. Browsers send OPTIONS without a JWT; these routes answer that preflight with 204 and keep admin auth on GET/POST.
GET /admin/customer-verification
- Auth: Admin JWT
- Description: Returns
email_verified/phone_verifiedfor POS and admin lists.
Query params:
| Param | Type | Required | Notes |
|---|---|---:|---|
| ids | string or string[] | No | Comma-separated customer ids. Max 100. |
Response:
{
"customers": {
"cus_1": { "email_verified": true, "phone_verified": false }
}
}A customer is registered when either flag is true.
POST /admin/customers/:id/otp/send
- Auth: Admin JWT
- Description: Sends the shop OTP (SMS/email) for staff-assisted POS verification. Reuses the same
send-otpworkflow as the storefront.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| type | phone_verification \| email_verification | Yes | Phone requires a phone on the customer. Email requires a real email (not @pos.customer.local or noreply+pos-guest@…). |
Response:
{ "token": "...", "expires_at": "..." }POST /admin/customers/:id/otp/verify
- Auth: Admin JWT
- Description: Confirms the OTP, sets the verified flag, and attaches storefront login the same way store verification OTP does. One verified channel is enough to treat the customer as registered (
has_account).
Body:
| Field | Type | Required |
|---|---|---:|
| token | string | Yes |
| code | string | Yes |
Response:
{
"verified": true,
"customer": { "id": "cus_...", "email": "...", "phone": "..." },
"email_verified": true,
"phone_verified": false
}GET /admin/account-deletion-requests
- Auth: Admin JWT
- Description: Lists account deletion requests with filtering/pagination.
Query params:
| Param | Type | Required | Default |
|---|---|---:|---|
| status | pending \| confirmed \| cancelled \| completed | No | none |
| limit | number | No | 20 |
| offset | number | No | 0 |
| order | created_at \| updated_at \| customer_id | No | created_at |
| order_direction | ASC \| DESC | No | DESC |
Response:
{
"requests": [],
"count": 0,
"offset": 0,
"limit": 20
}Services
OtpVerificationService
Manages OTP generation, validation, verification state updates, and channel config lookup.
Key methods:
generateOtpWithCode(input, jwtSecret)generateOtpForContactChange(input, jwtSecret, newValue, contactType)verifyOtp(input, jwtSecret)decodeContactChangeToken(token, jwtSecret)getCustomerVerificationByCustomerId(context, customerId)updateEmailVerified(context, customerId)updatePhoneVerified(context, customerId)
AccountDeletionRequestService
Manages deletion lifecycle records and admin/job access patterns.
Key methods:
createConfirmed(customer_id, deletion_scheduled_at)cancelRequest(customer_id)getActiveByCustomerId(customer_id)hasPendingRequest(customerId)hasActiveRequest(customerId)listForAdmin(selector, listConfig)listDueForDeletion(limit)markCompleted(id)
ReferralLinkModuleService
CRUD service for referral links (inherits generated methods from MedusaService).
PasswordManagementService
Utility service for password hash lookup/verification/update against provider identities.
CustomerRegistrationService
Placeholder module service for registration-related extension points.
Workflows & Steps
Workflows
| Workflow | Input | Output | Purpose |
|---|---|---|---|
| send-otp | { customer_id, type } | { token, expires_at } | Generic OTP send pipeline (customer lookup, config, token, notification). |
| send-contact-change-otp | { customer_id, new_value, contact_type, otp_type } | { token, expires_at } | Sends OTP to new email/phone; token embeds pending value. |
| update-contact | { customer_id, new_value, contact_type, login_identifier } | { customer_id, customer } | Updates contact, sets verified flags, syncs provider identity with compensation. |
| verify-email | { customer_id } | { customer_id, email_verified, customer } | Marks email verified and returns updated customer. |
| verify-phone | { customer_id, login_identifier? } | { customer_id, phone_verified, customer } | Marks phone verified and syncs phonepass entity id when needed. |
| change-password | { customer_id, old_password, new_password, confirm_password } | { customer_id, success } | Validates and updates password through auth provider. |
Steps
retrieve-customerresolve-channel-configdetermine-contact-methodgenerate-otpgenerate-contact-change-otpprepare-template-dataload-templatesend-notificationfind-customer-by-emailupdate-passwordsync-phonepass-entity-id
Subscribers / Event Hooks
auth.password_reset subscriber
- File:
src/subscribers/password-reset.ts - Event:
auth.password_reset - Behavior: renders configured password reset template and sends email notification using notification module.
Admin UI / Extensions
Account Deletion Requests page
- File:
src/admin/routes/account-deletion-requests/page.tsx - Placement: Admin route labeled Account Deletion Requests (trash icon)
- Renders:
- filterable/paginated table
- status badges (
pending,confirmed,cancelled,completed) - refresh + load more interactions
- Data source:
GET /admin/account-deletion-requests
Models & Entities
otp_verification
| Field | Type | Nullable |
|---|---|---:|
| id | text | No |
| customer_id | text | No |
| purpose | enum | No |
| hashed_code | text | No |
| expires_at | datetime | No |
| attempts | number | No |
| verified_at | datetime | Yes |
account_deletion_request
| Field | Type | Nullable |
|---|---|---:|
| id | text | No |
| customer_id | text | No |
| reason | text | Yes |
| deletion_scheduled_at | datetime | Yes |
| status | enum | No |
| cancelled_at | datetime | Yes |
referral_link
| Field | Type | Nullable |
|---|---|---:|
| id | text | No |
| customer_id | text | No |
| referrer_id | text | No |
| parent_customers_by_level | json | No |
Core Medusa relationships
customer_idfields link to Medusacustomertable by convention.- auth linkage is handled through
auth_identity.app_metadata.customer_idandprovider_identity.
Jobs
process-account-deletions
- Schedule: hourly (
0 * * * *) - Flow:
- load due confirmed requests
- delete auth identities for customer
- delete customer
- mark request as
completed
Use Cases & Examples
Phone-first storefront signup
UsePOST /auth/customer/emailpass/registerwith phone+password, thenPOST /store/customers, then OTP send/verify routes.Secure email/phone change in customer profile
UsePOST /store/customers/me/contact+POST /store/customers/me/contact/verifyinstead of direct patch.Regulatory account deletion process
Use request/confirm/cancel flows under/store/customers/account-deletion/*and rely on scheduled deletion job.Referral tree tracking for customers
Passreferral_codeon signup and fetch referrals viaGET /store/my-referrals.Channel-aware password reset
Use/auth/customer/emailpass/reset-passwordand/auth/customer/emailpass/updatewith email, phone, oremail_or_phone.
Known Issues & Fixes
Issue: Guest checkout + email OTP / Google created a second customer
Problem:
After guest checkout (has_account = false), logging in with email OTP or Google for the same email created a second registered customer instead of converting the guest. Orders stayed on the guest row.
Root Cause:
Medusa's createCustomerAccountWorkflow always inserts a new registered customer. Phone OTP already upgraded guests in place; email OTP and Google still called the create workflow even when lookupCustomerByEmail found the guest.
Solution:
upgradeEmailGuestCustomersetshas_account = trueon the same guest id.- Email OTP verify uses
resolveEmailAuthCustomerAction→upgrade-guestinstead of create. - Google
POST /store/customersreturnsupgradefromdecideSocialCustomerCreate, upgrades the guest, marks email verified, then links the Google auth identity.
Prevention:
- Prefer in-place guest upgrade for any passwordless / social login that proves email ownership.
- Keep create-customer workflows only for emails with no existing customer row.
Issue: Phone login created a second customer after email users verified a mobile
Problem: A customer logged in with email OTP or email password, verified a mobile in Account Section, logged out, then tried to log in with that mobile. The system treated them as a new user and could create a second customer.
Root Cause:
Account Section wrote customer.phone / phone_verified but only created phonepass when an emailpass password hash existed (email-OTP users got no phone credential). Phone OTP lookup used exact phone = ?, so 7265033341 did not match stored +917265033341. Verify then took the create path.
Solution:
- Phone customer/credential lookup uses the same canonical digit match as uniqueness.
- Verifying a mobile on an existing account attaches
phoneotpto that auth identity (andphonepasswhen a password hash can be copied). - Phone OTP verify prefers
findAuthIdentityIdByCustomerIdand never creates a second registered customer for that number.
Email-OTP users log in on the mobile with phone OTP. Email-password users can use the same password on the mobile after phonepass is copied.
Prevention:
- After Account Section phone verify, attach login credentials to the existing auth identity.
- Canonical-match phones in any lookup that decides login vs register.
Issue: Same mobile verified on two email accounts from Account Section
Problem:
Customers logged in with different emails could add and verify the same mobile from Account Section (POST /store/customers/me/contact + /verify).
Root Cause:
Uniqueness only blocked another verified owner (phone_verified = true) and compared customer.phone as an exact string. Unverified phones on other registered accounts were allowed, and 7265033341 did not match stored +917265033341 (or the reverse). The unique index IDX_customer_phone_verified_unique has the same exact-string hole. A unique index on all registered phones is not added here: shops may already have duplicates, and CREATE UNIQUE INDEX would fail migrate.
Solution: Phone claim checks now:
- treat canonical digit forms as the same number (
7265033341,917265033341,+917265033341) - reject when another registered customer (
has_account = true) already has the phone - still reject a verified owner or a login credential on another account
- run at OTP send, contact-change verify, phone-verification flag update, and registered customer create
Shops with existing duplicate verified phones in different formats should clean those rows. A later canonical unique index can follow after cleanup.
Prevention:
- Keep Account Section and verify-time gates on
assertContactClaimablefor phones. - Prefer registered-account uniqueness (
has_account = true) over “any customer row” so guest checkout / phone-OTP upgrade still works.
Issue: OTP login treats registered users as new when a guest row also exists
Problem:
For some returning users, email/phone OTP verify failed with Medusa's
Customer with this email already has an account (or the phone equivalent).
Send OTP could also report is_new_user: true incorrectly.
Root Cause:
Guest checkout and registration can leave two customer rows for the same
email or phone (has_account = true and a later has_account = false guest).
OTP lookup used ORDER BY created_at DESC LIMIT 1, so it preferred the newer
guest and the verify flow tried to create another account.
Solution:
lookupCustomerByEmail / lookupCustomerByPhone now order by
has_account DESC NULLS LAST, created_at DESC, so a registered account always
wins. Guest rows are used only when no registered account exists. No data
migration is required.
Prevention:
- Prefer registered customers (
has_account = true) in any identifier lookup that drives login vs register. - Keep regression tests that assert the SQL ordering contract.
POS admin OTP preflight returns CORS / 401
Problem: POS on another origin (http://localhost:8081) showed a CORS error on POST /admin/customers/:id/otp/send and GET /admin/customer-verification. Core admin routes like /admin/stock-locations worked.
Root Cause: Browsers send OPTIONS without the admin JWT. Plugin admin routes were not registered in the running shop (registry 0.0.140 instead of the built plugin), so Medusa authenticated the preflight and returned 401 without CORS headers.
Solution: These routes export AUTHENTICATE = false and an OPTIONS 204 handler. GET/POST still use authenticate("user", ["session", "bearer", "api-key"]). Consume the built plugin (.medusa/server), then restart Medusa.
Prevention: After changing this plugin, run npm run build and install that build into the shop. A file: symlink of the plugin source tree can fail shop boot (create-fulfillment-workflow already exists) because Medusa develop also sees the plugin's nested @medusajs packages.
Issue: plugin:build OOM while compiling admin extensions
Problem:
npm run build died during "Compiling plugin admin extensions..." with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory.
Root Cause:
medusa plugin:build writes generated __admin-extensions__.js while bundling admin UI. If that file (or a stub __admin-extensions__.ts) is left in src/admin, Vite treats it as source and re-bundles the extensions. Combined with the default ~2GB Node heap, the admin bundler OOMs.
Solution:
- Delete generated
src/admin/__admin-extensions__.js/.ts(they belong under.medusa, not source). - Gitignore those filenames.
- Run build with
NODE_OPTIONS='--max-old-space-size=8192'.
Prevention:
- Do not commit or keep generated
__admin-extensions__*undersrc/admin. - Keep the raised heap on
build/prepublishOnly.
Troubleshooting
plugin:build JavaScript heap out of memory
- Cause: leftover
src/admin/__admin-extensions__.js/.tsre-bundled as source, or Node's default ~2GB heap is too small for the Medusa admin bundler. - Fix: remove those generated files, then
npm run build(scripts now set--max-old-space-size=8192).
storefrontUrl is required when password_reset is configured
- Cause:
password_resetenabled withoutstorefrontUrl. - Fix: set
options.storefrontUrl.
OTP channel configuration not found
- Cause: missing
email_verification.channel,phone_verification.channel, or account deletion channel/template. - Fix: add channel config in plugin options.
Phone login is not enabled / Email login is not enabled
- Cause: credential channel does not match
login.identifier. - Fix: align request payload with config, or change
login.identifier.
Password reset succeeds with empty {} but user gets no message
- Cause: security behavior intentionally hides account existence and may swallow notification delivery errors.
- Fix: verify notification module config, template path, and channel provider setup.
Contact change verify returns unauthorized token mismatch
- Cause: OTP token used by different authenticated customer.
- Fix: ensure same customer JWT that initiated the contact-change request is used during verify.
Account deletion routes blocked / login blocked
- Cause: customer has active (
pending/confirmed) deletion request; guard middleware blocks most store routes. - Fix: complete cancel flow (
cancel-request+cancel-confirm) or allow job to complete deletion.
JWT secret is not configured
- Cause: Medusa HTTP JWT config missing.
- Fix: configure
projectConfig.http.jwtSecretin Medusa app config.
Helper Utilities
The package exports helper functions from customer-registration/helpers:
requestPasswordReset(input, options)completePasswordReset(input, options)createRequestPasswordReset(options)createCompletePasswordReset(options)
These support Medusa SDK client mode or direct fetch mode with optional x-publishable-api-key.
Migrations Included
Migration20250118001000CreateOtpVerificationTableMigration20250120000000RemoveForgotPasswordFromOtpPurposeMigration20250221000000AddAccountDeletionOtpPurposesMigration20250120000000AddCustomerVerificationColumnsMigration20250221000000CreateAccountDeletionRequestTableMigration20250221100000AddCompletedStatusToAccountDeletionRequestMigration20260502100000CreateReferralLinkTable
