Dejan Lukić

How to Enhance Your Next.js App with Email and SMS OTP

Originally written for Clerk.

Passwords have long been a widely used for securing digital accounts and information; however, they come with several significant drawbacks. One of the main cons of passwords is their susceptibility to various security threats.

Users often choose weak passwords or reuse them across multiple accounts, making them vulnerable to brute-force attacks and password guessing. Remembering multiple complex passwords can be challenging, making users write or store them insecurely.

Furthermore, passwords can be easily stolen through phishing attempts or keylogging malware, exposing sensitive data and compromising online identities.

As technology advances, the limitations of traditional passwords become more apparent, necessitating the adoption of more robust and sophisticated authentication methods to enhance digital security.

In this article, we will look at implementing email and SMS One-Time Passwords (OTP) in Next.js with Clerk - a user management platform.

Understanding OTP

One-Time Password (OTP) is a security mechanism used to enhance authentication. OTP involves generating a unique, temporary password valid for only a single login session or transaction, providing an additional layer of protection against unauthorized access.

OTPs can be delivered through various channels such as SMS, email, authenticator apps, or hardware tokens. The primary advantage of OTP lies in its time-limited nature, as the password becomes invalid after a short period, reducing the risk of password interception or replay attacks.

Online banking, two-factor authentication (2FA), multi-factor authentication (MFA), and other applications requiring heightened security measures widely employ OTPs.

By requiring users to enter a constantly changing and unique OTP alongside their regular credentials, OTP significantly strengthens the security posture of digital systems and safeguards sensitive information from potential threats.

Advantages of OTP

In the fast-paced digital world, safeguarding user data and fortifying app security is no longer optional – it's a necessity.

One-Time Passwords (OTP) emerge as a powerful ally, providing unmatched advantages that fortify your app's defenses against cyber threats. Here's why integrating OTP into your app is crucial:

Disadvantages of OTP

As with any security mechanism, OTP is no stranger to some disadvantages. Let’s take a look what can hold you back:

Security Considerations

When implementing OTP (One-Time Password) systems, several security considerations should be taken into account to ensure the effectiveness and reliability of the authentication method.

These considerations include ensuring secure delivery channels to minimize the risk of interception or tampering during transmission.

Time-based OTPs are recommended for enhanced security, as they have a limited validity window, reducing the chances of an attacker reusing intercepted OTPs.

Implementing rate limiting and account lockout policies is essential to prevent brute-force attacks on OTPs and protect user accounts from unauthorized access.

It is advisable to avoid using SMS-based OTPs due to vulnerabilities such as SIM swapping, although with more strict internal SIM-provider policies, this is very rare nowadays. Instead, consider using alternative methods like app-based OTPs or email OTPs.

Combining OTPs with other authentication factors, such as biometrics, creates a robust multi-factor authentication system with enhanced security.

By carefully considering these security aspects during the design and implementation of an OTP system, you can create a more robust and reliable authentication process, safeguarding user accounts and sensitive information from unauthorized access and cyber threats.

Setting Up Clerk

Setting up Clerk is very straightforward, navigate to your dashboard.

Start by creating a new application. In the wizard, enter the name of the application, and enable Phone number under “How will your users sign in”.

Clerk's authentication wizard

After creation of the Clerk project, navigate to User & Authentication > Email, Phone, Username > Authentication factors and enable SMS verification code.

Clerk's Authentication Factors setion

That’s it! Now head to setup the Next.js application!

Setting Up Next.js Application

In this article, we will use Next.js 13 and Clerk’s Next.js SDK to create a sign-in/sign-up OTP flow. Start by creating a new Node project.

 npm init -y

Create a new Next.js app. This command will use TypeScript by default, which we recommend:

npx create-next-app@latest

Once you have a Next.js application ready, you also need to install Clerk’s Next.js SDK library. The SDK contains prebuilt React components and hooks, allowing for fast time-to-market.

npm i @clerk/nextjs

Now, create a new .env.local file in the source of your project, which will contain all keys and endpoints. See keys in the Clerk dashboard.

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

Continue by mounting the <ClerkProvider> wrapper which will serve as the session and user context provider to the app. It is advised that the <ClerkProvider> wraps the <body> element, allowing context-accesibility anywhere within the app.

app/layout.tsx

// app/layout.tsx
import './globals.css'
import { Inter } from 'next/font/google'
import { ClerkProvider } from '@clerk/nextjs'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </ClerkProvider>
  )
}

If you intend to use the <ClerkProvider> outside the root layout, you must ensure it is also a server component, just like the root layout.

Protect the App

After giving the app Clerk’s context, you will now create a middleware, which will dictate which page should be public and which need to be behind an authentication wall.

Create a middleware.ts file in the root folder of your app (or inside src/ if you opted for that).

import { authMiddleware } from "@clerk/nextjs";

// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/nextjs/middleware for more information about configuring your middleware
export default authMiddleware({});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};

This will protect your entire application. By accessing the application you will be redirected to the Sign Up page. Read more about authMiddleware to see how to make other routes public.

Create Sign In and Sign Up Pages

We’ll use Clerk’s prebuilt components to create sign in and sign up pages with just a few lines of code. Clerk’s prebuilt components also extend to other user management functions. We’ll use <SignUp /> and <SignIn /> components by using Next.js optional catch-all route.

app/sign-up/[[…sign-up]]/page.tsx

import { SignUp } from "@clerk/nextjs";

export default function Page() {
  return <SignUp />;
}

app/sign-in/[[…sign-in]]/page.tsx

import { SignIn } from "@clerk/nextjs";

export default function Page() {
  return <SignIn />;
}

Add Endpoints to Environment Variables

Continue by adding the signIn, signUp, afterSignUp, and afterSignIn endpoints:

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

These values dictate how the components behave during sign-in, sign-up, and when you click on the links located at the bottom of each component.

Add the <UserButton /> component

The <User Button /> component allows you to manage your account and to perform log out. We’ll add it to the app/page.tsx.

app/page.tsx

import { UserButton } from "@clerk/nextjs";

export default function Home() {
  return (
    <div>
      <UserButton afterSignOutUrl="/"/>
    </div>
  )
}

Test the Sign Up

Start your application with npm run dev and navigate to [http://localhost:3000](http://localhost:3000) and sign up to access the application.

The rest of the guide will showcase how to set up passwordless authentication using SMS and email OTP.

Integrating SMS OTP - Sign Up

We will integrate SMS OTP with a passwordless sign-up flow just with a user identifier, in this case a phone number. The flow consists of the following steps:

  1. Collection of user’s identifier in the sign-up process
  2. Prepare the identifier verification
  3. Attempt to complete the identifier verification

Start by removing the <SignUp /> component in the sign-up page, importing new dependencies and declaring the component as client-side.

app/sign-up/[[…sign-up]]/page.tsx

"use client";
import { useSignUp } from "@clerk/nextjs";
import { useState } from "react";

Next, using useSignUp() hook take signUp and setActive methods:

const { signUp, setActive } = useSignUp();

Continue by creating 3 useState() hooks for conditional rendering later on:

const [showCodeField, setShowCodeField] = useState(false);
const [code, setCode] = useState("");
const [retry, setRetry] = useState(false);

showCodeField and setShowCodeField will be used to determine whether to show present the user with an input field for an OTP code.

code and setCode are used to access and store the OTP code later on.

Finally, retry and setRetry are used to show a message to the user indicating that they entered a wrong OTP code.

Input Handling

To handle the input changes, and to store the OTP code, we’ll create a simple method to handle the onChange input event.

const handleChange = (e: any) => {
    setCode(e.target.value);
};

Initiating the OTP delivery

Create a new method that will be triggered on a button press which will initiate a OTP code delivery to a specified phone number.

async function onClick(e: any) {
    e.preventDefault();
    // Kick off the sign-up process, passing the user's
    // phone number and show the input field for the OTP code
    setShowCodeField(true);
    if (signUp) {
      await signUp.create({
        phoneNumber: "+1111111111", // replace this with your phone number
      });
      // Prepare phone number verification. An SMS message 
      // will be sent to the user with a one-time 
      // verification code.
      await signUp.preparePhoneNumberVerification();
    }
}

Verifying the OTP code

To verify the OTP code, we will have another method that will be called upon submitted the OTP code by the user. The method will attempt to verify the code sent to above provided phone number, and if it fails it will show a message to the user.

Upon succeeding, the method will add the user session to the browser.

async function submit(e: any) {
    e.preventDefault();
    if (signUp) {
      await signUp.attemptPhoneNumberVerification({
        code,
      }).catch((err) => {
        if (err.status === 422) {
          setRetry(true);
        }
        // soft-fail
      });
      await setActive({ session: signUp.createdSessionId || "" });
    }
  }

Rendering the Components

We’ll show everything simply with 2 buttons and an input field, with conditional rendering to hide some elements when they’re not necessary.

return (
    <>
      <button onClick={onClick}>
        Sign up without password
      </button>
      {showCodeField && (
        <>
          <input type="text" onChange={handleChange} />
          <button onClick={submit}>Submit</button>
          {retry && <>Please try again.</>}
        </>
      )}
    </>
  );

Integrating SMS OTP - Sign In

Integrating SMS OTP for sign-ins requires just a few tweaks to the existing code. As previously, remove the <SignIn /> component and import the dependencies:

"use client";
import { SignIn } from "@clerk/nextjs";
import { useSignIn } from "@clerk/clerk-react";
import { useState } from "react";

Add the hooks and the handleChange method as previously:

const { signIn, setActive } = useSignIn();
const [showCodeField, setShowCodeField] = useState(false);
const [code, setCode] = useState("");
const [retry, setRetry] = useState(false);
const handleChange = (e: any) => {
  setCode(e.target.value);
};

Initiating the OTP delivery

Create a new method that will be triggered on a button press which will initiate a OTP code delivery to a specified phone number.

async function onClick(e: any) {
    if (signIn) {
      e.preventDefault();
      // Kick off the sign-in process, passing the user's 
      // authentication identifier. In this case it's their
      // phone number.
      const { supportedFirstFactors } = await signIn.create({
        identifier: "+1111111111",
      });

      // Find the phoneNumberId from all the available first factors for the current sign in
      const firstPhoneFactor = supportedFirstFactors.find(factor => {
        return factor.strategy === 'phone_code'
      });

      const { phoneNumberId } = firstPhoneFactor;
      // Prepare first factor verification, specifying 
      // the phone code strategy.
      await signIn.prepareFirstFactor({
        strategy: "phone_code",
        phoneNumberId,
      });
    }
  }

Verifying the OTP code

In this case we will use attemptFirstFactor method to verify the OTP code. This is a required step in order to complete a sign in, as users should be verified at least by one factor of authentication.

async function submit(e: any) {
    // Attempt to verify the user providing the
    // one-time code they received.
    if (signIn) {
      await signIn.attemptFirstFactor({
        strategy: "phone_code",
        code,
      });
      await setActive({ session: signIn.createdSessionId });
    }
  }

Testing SMS OTP

Navigate to [http://localhost:3000](http://localhost:3000) or [http://localhost:300](http://localhost:3000)0/sign-up and press “Sign in without password”, or “Sign up without password”, respectively.

Test app in the browser

After pressing either of these, you will receive an OTP code on the phone number defined in the application.

Clerk's OTP code sent to SMS

After entering the code the user session will be added in the browser’s storage, and upon refreshing you will be logged in and shown the <UserButton /> component on page.tsx.

In case you fail to enter the right code, you will be presented with an error message.

Soft-fail shown in the app

OTP Expiration

Clerk’s SMS OTP codes are set to expire after 10 minutes. Afterwards, the same 422 error will be returned, and as such the code will be deemed invalid.

Integrating Email OTP - Sign Up

In addition to phone number verification, you have the option to verify your users using their email address. Two supplementary helper methods, namely prepareEmailAddressVerification and attemptEmailAddressVerification, function similarly to their phone number counterparts.

Let’s implement them by simply tweaking the previous code. To keep it simple, we’ll just show how to edit the onClick and submit methods for sign up and sign in. By now you should differentiate sign up and sign in logic, but feel free to always go back and rehearse.

Start by editing the onClick method:

async function onClick(e: any) {
    e.preventDefault();
    // Kick off the sign-up process, passing the user's
    // email address.
    setShowCodeField(true);
    if (signUp) {
      await signUp.create({
        emailAddress: "foo@bar.xyz",
      });

      // Prepare email verification. An email 
      // will be sent to the user with a one-time 
      // verification code.
      await signUp.prepareEmailAddressVerification();
    }
  }

We just changed the emailAddress in the create() method’s argument, as well as initiated the prepareEmailAddressVerification() method.

Finish by editing the submit() method:

async function submit(e: any) {
    e.preventDefault();
    if (signUp) {
      await signUp.attemptEmailAddressVerification({
        code,
      }).catch((err) => {
        if (err.status === 422) {
          setRetry(true);
        }
        // soft-fail
      });
      await setActive({ session: signUp.createdSessionId || "" });
    }
  }

Here, we replaced attemptPhoneNumberVerification() method with the attemptEmailAddressVerification() method. Easy!

Testing Email OTP

Navigate to [http://localhost:3000](http://localhost:3000) or [http://localhost:3000/sign-up](http://localhost:3000/sign-up) and press “Sign in without password”, or “Sign up without password”, respectively, as previously.

Test app shown in the browser

After pressing either of these, you will receive an OTP code on the email address defined in the application.

Clerk's OTP code sent to the email

After entering the code the user session will be added in the browser’s storage, and upon refreshing you will be logged in and shown the <UserButton /> component on page.tsx.

In case you fail to enter the right code, you will be presented with an error message.

That’s it! Super simplified with the power of Clerk!

Enhancing Security Measures

To prevent brute-force attacks, you can implement rate-limiting, a technique which will allow the user a certain amount requests per second, or minute.

We’ll use two libraries - @upstash/ratelimit and @upstash/redis. To keep it simple, we’ll add it in each file, but we advise to create a global rate limiter in the middleware.ts.

Install the dependencies with npm:

npm i @upstash/ratelimit @upstash/redis

Import the following dependencies to both sign up and sign in pages:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

Instantiate the rate limiter:

const rateLimiter = new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.slidingWindow(2, "3 s")
});

Under the hood, the rate limiter is using Redis - an in-memory storage database, as a cache to store requests with their originator.

By providing the slidingWindow() method, we use the sliding window algorithm that limits the number of requests a user can make within a given time frame while providing a smoother distribution of requests.

Using conditional logic and with user’s IP, you can later decide to render an error message. Here’s a simple pseudo-code example:

// get user's ip
const { success } = await rateLimiter.limit(user_ip);
if (success) return <component />

Next Steps and Resources

In this guide you learned how to implement two very useful and secure methods of authentication - SMS and email one-time passwords, with Clerk - a powerful user management platform.

Learn more about Clerk on the blog. Have an issue within the code? Refer to the extensive documentation.

Continue by implementing multi-factor authentication (MFA) or perhaps some 🪄magic links to make your sign up and sign-in processes smooth as butter.

Writer’s note: Usually writers don’t practice including these notes, but as an avid developer and an entrepreneur in the tech space, Clerk is saving us so much time. Implementing authentication (and later authorization) is a very time draining process, but with Clerk’s generous plans and extensive docs we were able to ship full authentication (with MFA and SSO) in 3 days. Multiply those 3 days with the dev’s hourly rate and you’ll see that Clerk pays itself in less than an hour.