customer-registration
v0.0.113
Published
Medusa plugin that overrides store customer registration, enforces email/phone verification flags, and provides OTP management module.
Maintainers
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
- Phone-only Authentication:
phonepassauth provider for registering and logging in with phone + password (no email required) - 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
- Install the plugin:
npm install customer-registration- 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: {
template: "src/templates/emails/password-reset.html",
subject: "Reset Your Password",
},
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",
},
},
},
],
})- Run migrations:
npx medusa db:migrate- Use the API — see API Endpoints below.
📖 For complete documentation, see USAGE.md
Installation
Local Development
- Publish the plugin to local registry:
cd plugins/customer-registration
npx medusa plugin:publish- Install in your Medusa application:
cd ../../test-medusa
npx medusa plugin:add customer-registrationRegister the plugin in
medusa-config.ts(see Quick Start above).Start development mode (in plugin directory):
cd plugins/customer-registration
npx medusa plugin:develop- Start your Medusa application:
cd ../../test-medusa
yarn devConfiguration
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 only |
| "phone" | POST /auth/customer/phonepass only |
| "both" | Both emailpass and phonepass accepted |
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, // Required when using password reset
subject?: string,
},
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
| Endpoint | Method | Description |
|---|---|---|
| /auth/customer/emailpass | POST | Login with email + password |
| /auth/customer/phonepass | POST | Login with phone + password |
| /auth/customer/emailpass/register | POST | Create emailpass auth identity |
| /auth/customer/phonepass/register | POST | Create phonepass auth identity |
Contact Change (Phone or Email Update)
These are the dedicated authenticated routes for changing a customer's phone or email.
PATCH /store/customers/merejectsphoneand
| 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:
customer.phone/customer.emailis updatedphone_verified/email_verifiedis set totrueprovider_identity.entity_idis updated forphonepass/emailpassonly when the changed field is the activelogin.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_idsync has a compensation function: if a later workflow step fails theentity_idis 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
| Endpoint | Method | Description |
|---|---|---|
| /auth/customer/emailpass/reset-password | POST | Request password reset email |
| /auth/customer/emailpass/update | POST | Complete password reset with token |
# Request reset
POST /auth/customer/emailpass/reset-password
{ "identifier": "[email protected]" }
# Complete reset
POST /auth/customer/emailpass/update
{ "token": "<reset_token>", "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
POST /auth/customer/phonepass/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/phonepass
{ "phone": "+15551234567", "password": "SecretPass1!" }
# Response: { "token": "<jwt>" }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:
otp-verification— OTP generation, verification, and managementcustomer-registration— Customer registration logic and route overridesaccount-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:migratePassword Reset (Helper Functions)
import {
requestPasswordReset,
completePasswordReset,
} from "customer-registration/helpers"
await requestPasswordReset(
{ email: "[email protected]" },
{ 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) phonepassprovider registered in auth module (required whenregistration.identifieris"phone"or"both")
Documentation
Development
# Build
npm run build
# Watch mode
npx medusa plugin:developLicense
MIT
