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: [
    "@perseidesjs/auth-otp"
  ],
  // ✅ 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
})

By doing this, you’re letting Medusa know that a new authentication provider is available and that new identities can now be created to create actors using the OTP. In this way, if you want to allow your users to create their accounts either using email and password, or using the OTP, this is possible.

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>"
  }'

When this route is triggered, and no actors with the provided identifier exist, an event will be emitted. We can subscribe to this event to send the generated OTP to the user.

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
}

Once the OTP is sent to the user, it will be verified on the native Medusa register route. This verification process will allow you to obtain the token necessary to create an actor (customer, user, etc.).

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/authenticate', {
      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.