Skip to content

Email OTP

Authenticate with a one-time code

Email OTP is a two-step login flow: send a code to the user's email address, then verify the code they enter. Use OTP when you want a familiar email-based flow without redirecting the user away from your app. After verification succeeds, the ZeroDev Wagmi connector is connected.

Add OTP login

Use useSendOTP to send the code and useVerifyOTP to complete authentication.

import { useSendOTP, useVerifyOTP } from '@zerodev/wallet-react'
import { useState } from 'react'
import { useAccount, useDisconnect } from 'wagmi'
 
export function EmailOTPLogin() {
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [otpId, setOtpId] = useState<string | null>(null)
  const [otpEncryptionTargetBundle, setOtpEncryptionTargetBundle] =
    useState<string | null>(null)
 
  const { address, isConnected } = useAccount()
  const { disconnect } = useDisconnect()
  const sendOTP = useSendOTP()
  const verifyOTP = useVerifyOTP()
 
  if (isConnected) {
    return (
      <div>
        <p>Connected: {address}</p>
        <button type="button" onClick={() => disconnect()}>
          Disconnect
        </button>
      </div>
    )
  }
 
  if (!otpId) {
    return (
      <div>
        <input
          type="email"
          autoComplete="email"
          placeholder="you@example.com"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
        />
 
        <button
          type="button"
          disabled={sendOTP.isPending || !email}
          onClick={async () => {
            const result = await sendOTP.mutateAsync({ email })
            setOtpId(result.otpId)
            setOtpEncryptionTargetBundle(result.otpEncryptionTargetBundle)
          }}
        >
          {sendOTP.isPending ? 'Sending code...' : 'Send code'}
        </button>
 
        {sendOTP.error ? <p>{sendOTP.error.message}</p> : null}
      </div>
    )
  }
 
  return (
    <div>
      <p>Code sent to {email}</p>
 
      <input
        inputMode="numeric"
        autoComplete="one-time-code"
        placeholder="Enter code"
        value={code}
        onChange={(event) => setCode(event.target.value)}
      />
 
      <button
        type="button"
        disabled={verifyOTP.isPending || !code || !otpEncryptionTargetBundle}
        onClick={() =>
          otpEncryptionTargetBundle &&
          verifyOTP.mutate({
            otpId,
            otpEncryptionTargetBundle,
            code,
          })
        }
      >
        {verifyOTP.isPending ? 'Verifying...' : 'Verify code'}
      </button>
 
      <button
        type="button"
        onClick={() => {
          setOtpId(null)
          setOtpEncryptionTargetBundle(null)
        }}
      >
        Use a different email
      </button>
 
      {verifyOTP.error ? <p>{verifyOTP.error.message}</p> : null}
    </div>
  )
}

How it works

  1. useSendOTP sends a one-time code and returns an otpId plus an otpEncryptionTargetBundle.
  2. Store both values while the user enters the code.
  3. useVerifyOTP verifies the otpId, otpEncryptionTargetBundle, and code.
  4. After verification succeeds, the SDK creates a session and connects the ZeroDev Wagmi connector.

Notes

  • Keep the otpId and otpEncryptionTargetBundle on the client until verification completes. For a single-page flow, component state is enough.
  • The authenticated user's email and linked login methods are available through useAuthenticators.
  • If you need link-based email login instead of manual code entry, use Magic Link.

Next steps