# Send one-time passcode (OTP) emails

Source: https://developer.nylas.com/docs/cookbook/email/send-otp-emails/

A one-time passcode is a short, throwaway secret you mail to a user so they can prove they control an inbox. It backs sign-in, signup verification, and step-up checks for sensitive actions. This recipe covers the sending side of that flow: how you generate a code, deliver it from your own domain, and verify it safely when the user submits it.

The delivery itself is application mail, not mail from a connected user account, so it fits the transactional send route. Everything around the message, the code generation, storage, expiry, and rate limiting, is your own code. There is no OTP endpoint to call.

## How do I send a one-time passcode by email?

Send the code with the transactional `POST /v3/domains/{domain_name}/messages/send` route, the same domain route used for password resets and receipts. There is no user grant and no OAuth, just your API key and a verified domain. Put a freshly generated code in the body, keep the subject short, and set a 600 second expiry on your side so a stale code stops working.

OTP delivery follows the same message shape as any transactional send. The example below mails a 6-digit code from a verified domain. Generate the code with a cryptographically secure random number generator before you build the request, never with `Math.random()` or a time seed.

```js


// 6-digit code, 000000-999999, from a CSPRNG.
const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, "0");

await fetch(`https://api.us.nylas.com/v3/domains/${DOMAIN}/messages/send`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${NYLAS_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: { name: "Acme Security", email: `no-reply@${DOMAIN}` },
    to: [{ email: userEmail }],
    subject: "Your verification code",
    body: `Your code is ${code}. It expires in 10 minutes.`,
  }),
});
```

See [Send transactional email from a domain](/docs/cookbook/email/transactional-send/) for the full request reference, and the [transactional send quickstart](/docs/v3/getting-started/transactional-send/) for domain verification. Don't duplicate that setup here; verify the domain once and reuse it.

## Send an OTP from the terminal

The [Nylas CLI](https://cli.nylas.com/docs/commands) sends a one-time passcode with `nylas email send`, the same command you'd script into a signup flow. For OTPs you usually skip open and link tracking, since the email is transactional and short-lived, and you deliver it the moment the user asks.

```bash
nylas email send --to user@example.com --subject "Your code: 481920" --body "Your one-time passcode is 481920. It expires in 10 minutes."
```

Keep OTP mail plain and immediate: no `--schedule`, no `--track-opens`, so the code arrives instantly. Generate and store the code server-side, set a short expiry like 10 minutes, and send it with one call. See the [`email send`](https://cli.nylas.com/docs/commands/email-send) command reference.

## How do I store an OTP securely?

Treat the code as a credential. Hash it the moment you generate it with an HMAC keyed on a server-side secret, persist only the hash alongside a user identifier and an expiry timestamp, and discard the plain code after the message is sent. On submit, hash the input and compare it to the stored hash. Never log the value.

A 6-digit numeric code has only 1,000,000 possible values, so a bare hash is weak. The HMAC stops an attacker who steals the table from precomputing all 1,000,000 hashes offline, and a strict 5-attempt limit closes the online guessing path.

Keep the expiry tight. A window of 600 seconds is a sensible default; longer windows widen the guessing surface for no real usability gain. Store the expiry as an absolute timestamp so a clock check is a single comparison.

```js


function hashCode(code) {
  // HMAC with a server-side secret (a random 32-byte value) so a stolen
  // table can't be reversed by precomputing all 1,000,000 codes offline.
  return crypto.createHmac("sha256", process.env.OTP_HMAC_SECRET).update(code).digest("hex");
}

await db.otp.upsert({
  userId,
  codeHash: hashCode(code),
  expiresAt: Date.now() + 600_000, // 600 seconds
  attempts: 0,
});
```

## How do I verify the code on submit?

Verification runs entirely in your code. Look up the stored record for the user, reject if it is missing or past its 600 seconds expiry, then compare the hash of the submitted 6 digits against the stored hash with a constant-time comparison so you never leak timing information.

Allow at most 5 attempts per code, increment a counter on each wrong guess, and delete the record once the count is reached so a sixth attempt has nothing to check against. Delete the record on the first success too, so a code never works twice. The check below enforces expiry, the attempt ceiling, and single use in one place.

```js
async function verifyOtp(userId, submitted) {
  const record = await db.otp.find({ userId });
  if (!record) return false;

  if (Date.now() > record.expiresAt || record.attempts >= 5) {
    await db.otp.delete({ userId });
    return false;
  }

  const ok = crypto.timingSafeEqual(
    Buffer.from(hashCode(submitted)),
    Buffer.from(record.codeHash),
  );

  if (!ok) {
    const attempts = record.attempts + 1;
    if (attempts >= 5) {
      await db.otp.delete({ userId }); // burn the code on the 5th wrong guess
    } else {
      await db.otp.update({ userId, attempts });
    }
    return false;
  }

  await db.otp.delete({ userId }); // single use
  return true;
}
```

## How do I rate-limit OTP requests to prevent abuse?

Cap OTP sends per recipient and per source. A common policy is at most 3 send requests per 15 minute window per address, plus a minimum 60 second cooldown between consecutive sends to the same user. Track these counts in a fast store like Redis and reject over-limit requests before you call the transactional route, not after.

Without limits, the send endpoint becomes a way to flood an address or burn through your sending reputation. The example keys by recipient; add a second key by source IP or account ID the same way so one client cannot rotate recipient addresses to slip past the 3 per window cap.

Pair the request limit with the verify-side attempt ceiling from the previous section. Together they close both directions of abuse: too many codes sent and too many guesses made. If you see repeated limit hits for one address, treat it as a signal and back off further or alert.

```js
async function canSend(userEmail) {
  // 60 second cooldown between consecutive sends, set atomically so two
  // concurrent requests can't both pass.
  const cooldown = `otp:cooldown:${userEmail}`;
  const fresh = await redis.set(cooldown, "1", "NX", "EX", 60);
  if (fresh === null) return false; // a send is already within the cooldown

  const key = `otp:rate:${userEmail}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 900); // 900 second window
  return count <= 3;
}
```

## Things to know about sending OTP email

Deliverability matters more for OTP than for most mail, because a code that arrives after its 600 seconds expiry is a failed login. Transactional send emits delivery events you should watch so a bounced or rejected code triggers a fallback such as showing the user a retry option. Subscribe to the `message.transactional.bounced`, `message.transactional.delivered`, `message.transactional.rejected`, and `message.transactional.complaint` events covered in the transactional guide.

Keep the message itself minimal. One code, a one line expiry note, no marketing. Mixing promotional content into a verification message hurts both deliverability and clarity. Transactional send is in beta, so behavior is stable but the API may change before general availability.

## What's next

- [Send transactional email from a domain](/docs/cookbook/email/transactional-send/) for the full domain send reference
- [Transactional send quickstart](/docs/v3/getting-started/transactional-send/) for domain verification and your first send
- [Extract an OTP or 2FA code](/docs/cookbook/agent-accounts/extract-otp-code/) for the receiving side of an OTP flow
- [Send email](/docs/v3/email/send-email/) for the message field reference the domain route reuses