customer-registration
v0.0.134
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)
- 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 (creates account with phone only). - 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.
Body:
| Field | Type | Required | Notes |
|---|---|---:|---|
| email | string | XOR | Exactly one of email/phone. |
| phone | string | XOR | Exactly one of email/phone. |
Response:
{ "token": "...", "expires_at": "..." }POST /store/customers/me/contact/verify
- Auth: Customer JWT
- Description: Verifies OTP and atomically updates contact + verification flags.
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
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.
Troubleshooting
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
