Authenticate with OTP
Once installed, the plugin exposes API endpoints for generating and verifying OTPs.
Intercepting the event
Like any event on Medusa, you just need to create a “Subscriber” to intercept the event and send it to your users with your favorite notification provider.
import {
  SubscriberArgs,
  type SubscriberConfig,
} from "@medusajs/framework"
import { type OtpGeneratedEvent, Events } from "@perseidesjs/auth-otp/types"
export default async function otpGeneratedHandler({
  event: { data },
  container
}: SubscriberArgs<OtpGeneratedEvent>) {
  const { otp, identifier } = data
  console.log("An OTP was generated :", otp)
  // Use any notification provider to send the OTP to the user
}
export const config: SubscriberConfig = {
  event: Events.OTP_GENERATED, // ✅ This event is emitted when the OTP is generated
}
Generating an OTP
Let’s dive into the first step of the OTP flow. This API route will emit an event that you should handle to send the OTP to your users.
curl --request POST \
  --url http://localhost:9000/auth/{actor_type}/otp/generate \
  --header 'Content-Type: application/json' \
  --data '{
    "identifier": "<string>"
}'
The {actor_type} is the type of the actor you want to generate the OTP for. It can be customer, user or any custom actor type.
Verifying an OTP
Last step, you can verify the OTP using the following endpoint to get a fresh JWT token:
curl --request POST \
  --url http://localhost:9000/auth/{actor_type}/otp/verify \
  --header 'Content-Type: application/json' \
  --data '{
    "identifier": "<string>",
    "otp": "<string>"
}'
Response
If the OTP is valid, the endpoint returns a JWT token that can be used for subsequent requests:
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Create new actors without a password
If you want to allow your users to sign up without a password, you can use the OTP as an Auth Provider.
Adding the OTP provider
For that, you’ll need to update your medusa-config.ts file to add the OTP Auth Provider on top of your other auth providers(e.g. emailpass).
import { loadEnv, defineConfig, Modules, ContainerRegistrationKeys } from '@medusajs/utils'
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
module.exports = defineConfig({
  plugins: [
    { resolve: "@perseidesjs/auth-otp", options: {} }
  ],
  // ✅ We need to extend the auth module to add the OTP provider
  modules: [
    {
      resolve: "@medusajs/medusa/auth",
      options: {
        providers: [
          // Medusa default provider
          {
            id: "emailpass",
            resolve: "@medusajs/medusa/auth-emailpass",
            dependencies: [Modules.CACHE, ContainerRegistrationKeys.LOGGER],
          },
          // 🆕 Our OTP provider
          {
            id: "otp",
            resolve: "@perseidesjs/auth-otp/providers/otp",
            dependencies: [Modules.CACHE, ContainerRegistrationKeys.LOGGER],
          },
        ],
      },
    },
  ],
  // ... rest
})
You cannot create an account using the OTP provider for an identifier (e.g., email) that already exists with another auth provider (e.g., emailpass). This will throw a duplicate error from Medusa.
Pre-register process
Before we can create new accounts using the OTP provider, we need to go through the pre-registration process.
This process essentially validates the identifier used before retrieving the registration token that will allow us to create an account.
curl --request POST \
  --url http://localhost:9000/auth/{actor_type}/otp/pre-register \
  --header 'Content-Type: application/json' \
  --data '{
    "identifier": "<string>"
  }'
import {
  SubscriberArgs,
  type SubscriberConfig,
} from "@medusajs/framework"
import { type OtpGeneratedEvent, Events } from "@perseidesjs/auth-otp/types"
export default async function otpGeneratedHandler({
  event: { data },
  container
}: SubscriberArgs<OtpGeneratedEvent>) {
  const { otp, identifier } = data
  console.log("An OTP was generated :", otp)
  // Use any notification provider to send the OTP to the user
}
export const config: SubscriberConfig = {
  event: Events.PRE_REGISTER_OTP_GENERATED, // ✅ This event is emitted when the OTP is generated for the pre-register process
}
curl --request POST \
  --url http://localhost:9000/auth/{actor_type}/otp/register \
  --header 'Content-Type: application/json' \
  --data '{
    "identifier": "<string>",
    "otp": "<string>"
  }'
Authenticating after Registration
After successfully registering a user with OTP, we can authenticate them immediately without requiring a second OTP verification. This improves the user experience by eliminating the need for users to enter an OTP twice.
The current implementation allows for a short window (60 seconds) where the same OTP used for registration can be used for immediate authentication. Additionally, this OTP post-registration is useable only once, ensuring that it cannot be reused for subsequent authentication attempts. Here’s an example of how it will look like in the Medusa Next.js Starter for example :
export async function signup(_currentState: unknown, formData: FormData) {
  const customerForm = {
    email: formData.get("email") as string,
    first_name: formData.get("first_name") as string,
    last_name: formData.get("last_name") as string,
  }
  const otp = formData.get("otp") as string
  try {
    // 1. Register the user with OTP and get the registration token
    const registrationToken = await sdk.client.fetch<{ token: string }>('/auth/customer/otp/register', {
      method: 'POST',
      body: {
        identifier: customerForm.email,
        otp,
      }
    })
    // ... specific to Next.js cookies
    await setAuthToken(registrationToken.token)
    const headers = {
      ...(await getAuthHeaders()),
    }
    // 2. Create the customer
    const { customer: createdCustomer } = await sdk.store.customer.create(
      customerForm,
      {},
      headers
    )
    // 3. Authenticate the user with the same OTP
    const authenticationToken = await sdk.client.fetch<{ token: string }>('/auth/customer/otp', {
      method: 'POST',
      body: {
        identifier: customerForm.email,
        otp,
      }
    })
    await setAuthToken(authenticationToken.token)
    const customerCacheTag = await getCacheTag("customers")
    revalidateTag(customerCacheTag)
    await transferCart()
    return createdCustomer
  } catch (error: any) {
    return error.toString()
  }
}
Next Steps
For more advanced configuration options and customizations, refer to the “Customization” guide.