CastBricks Docs

Phone Verification with Node.js

Build a complete OTP phone verification flow using Node.js, Express, and CastBrick

Phone Verification with Node.js

This tutorial builds a production-ready OTP (one-time password) flow: a user enters their phone number, receives a 6-digit code via SMS, and verifies it to confirm their identity.

What you'll build

POST /auth/send-otp    → generates a code, sends it via CastBrick
POST /auth/verify-otp  → validates the code and returns a session token

Prerequisites

  • Node.js 18+
  • A CastBrick API key from the dashboard

Setup

mkdir otp-demo && cd otp-demo
npm init -y
npm install castbrick-js express dotenv

Create a .env file:

CASTBRICK_API_KEY=your_api_key_here

The code

Create server.mjs:

import 'dotenv/config';
import express from 'express';
import { CastBrick, CastBrickApiError } from 'castbrick-js';

const app = express();
app.use(express.json());

const cb = new CastBrick({ apiKey: process.env.CASTBRICK_API_KEY });

// In production, replace this with Redis or a database
const otpStore = new Map(); // phone → { code, expiresAt, attempts }

const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutes
const MAX_ATTEMPTS = 3;

function generateOtp() {
  return String(Math.floor(100000 + Math.random() * 900000));
}

// POST /auth/send-otp
app.post('/auth/send-otp', async (req, res) => {
  const { phone } = req.body;

  if (!phone) {
    return res.status(422).json({ error: 'phone is required' });
  }

  const code = generateOtp();
  otpStore.set(phone, {
    code,
    expiresAt: Date.now() + OTP_TTL_MS,
    attempts: 0,
  });

  try {
    await cb.sms.send({
      recipients: [phone],
      content: `Your CastBrick verification code is ${code}. It expires in 10 minutes. Do not share it.`,
      senderId: 'Verify',
    });

    res.json({ ok: true, message: 'Code sent' });
  } catch (err) {
    otpStore.delete(phone);
    if (err instanceof CastBrickApiError) {
      return res.status(err.status).json({ error: err.message });
    }
    res.status(500).json({ error: 'Failed to send SMS' });
  }
});

// POST /auth/verify-otp
app.post('/auth/verify-otp', (req, res) => {
  const { phone, code } = req.body;

  if (!phone || !code) {
    return res.status(422).json({ error: 'phone and code are required' });
  }

  const record = otpStore.get(phone);

  if (!record) {
    return res.status(400).json({ error: 'No code was sent to this number' });
  }

  if (Date.now() > record.expiresAt) {
    otpStore.delete(phone);
    return res.status(400).json({ error: 'Code has expired' });
  }

  record.attempts += 1;

  if (record.attempts > MAX_ATTEMPTS) {
    otpStore.delete(phone);
    return res.status(429).json({ error: 'Too many attempts. Request a new code.' });
  }

  if (record.code !== code) {
    return res.status(400).json({ error: 'Invalid code' });
  }

  otpStore.delete(phone);
  // Replace with your real session/JWT logic
  res.json({ ok: true, token: 'session_token_here' });
});

app.listen(3000, () => console.log('Server running on http://localhost:3000'));

Test it

Send a code:

curl -X POST http://localhost:3000/auth/send-otp \
  -H "Content-Type: application/json" \
  -d '{"phone": "+244923000000"}'

Verify it:

curl -X POST http://localhost:3000/auth/verify-otp \
  -H "Content-Type: application/json" \
  -d '{"phone": "+244923000000", "code": "123456"}'

Going to production

Replace the in-memory otpStore with Redis to support multiple servers and survive restarts:

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// Store OTP with 10-minute TTL
await redis.setEx(`otp:${phone}`, 600, JSON.stringify({ code, attempts: 0 }));

// Read
const raw = await redis.get(`otp:${phone}`);
const record = raw ? JSON.parse(raw) : null;

// Delete after successful verification
await redis.del(`otp:${phone}`);

Never log OTP codes. In production, use HTTPS and add IP-based rate limiting (e.g., express-rate-limit) on the /auth/send-otp endpoint.

Next steps