Skip to content
LIAM RECKZIEGELEngineer / builder / curious person
Blog Post

Building Invite-Only Onboarding with OTP Verification

Complete implementation of invite-only onboarding with OTP verification to enforce exclusive access and protect community integrity.

Author: Liam ReckziegelBlog Post · deep dive

Building Invite-Only Onboarding with OTP Verification

December 9, 2024 (2y ago)

The Problem

Building a women-only community app required strict access control. Traditional email/password signup wouldn't work—we needed to:

  1. Verify eligibility before allowing signup
  2. Prevent fake accounts and bad actors
  3. Scale invite distribution without manual approval
  4. Maintain community safety as we grew

Solution: Invite-only onboarding with One-Time Password (OTP) verification.

This pattern is ideal for communities requiring verified access control. Combine invite codes with phone verification to prevent unauthorized signups while maintaining a smooth user experience.

System Architecture

User requests invite
       ↓
Admin approves (generates invite code)
       ↓
SMS sent with unique code + OTP
       ↓
User enters invite code
       ↓
Backend validates invite code
       ↓
OTP sent to phone number
       ↓
User enters OTP
       ↓
Account created (invite consumed)

Database Schema

-- Invite codes table
CREATE TABLE invite_codes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code VARCHAR(8) UNIQUE NOT NULL,
  phone_number VARCHAR(15) NOT NULL,
  created_by UUID REFERENCES users(id),
  created_at TIMESTAMP DEFAULT NOW(),
  expires_at TIMESTAMP NOT NULL,
  used_at TIMESTAMP,
  used_by UUID REFERENCES users(id),
  status VARCHAR(20) DEFAULT 'pending', -- pending, used, expired, revoked
  metadata JSONB
);

-- OTP tokens table
CREATE TABLE otp_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  phone_number VARCHAR(15) NOT NULL,
  code VARCHAR(6) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  expires_at TIMESTAMP NOT NULL,
  verified_at TIMESTAMP,
  attempts INT DEFAULT 0,
  max_attempts INT DEFAULT 3
);

-- Indexes
CREATE INDEX idx_invite_codes_code ON invite_codes(code) WHERE status = 'pending';
CREATE INDEX idx_invite_codes_phone ON invite_codes(phone_number);
CREATE INDEX idx_otp_phone ON otp_tokens(phone_number) WHERE verified_at IS NULL;

Backend Implementation

Generating Invite Codes

// services/inviteService.ts
import { randomBytes } from 'crypto';
import { db } from './database';
import { smsService } from './smsService';

export class InviteService {
  /**
   * Generate a unique, human-readable invite code
   */
  private generateInviteCode(): string {
    // Use base32 for readability (no ambiguous characters)
    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    let code = '';
    
    for (let i = 0; i < 8; i++) {
      const randomIndex = randomBytes(1)[0] % chars.length;
      code += chars[randomIndex];
    }
    
    return code;
  }

  /**
   * Create and send an invite
   */
  async createInvite(params: {
    phoneNumber: string;
    createdBy: string;
    expiresInDays?: number;
  }) {
    const { phoneNumber, createdBy, expiresInDays = 7 } = params;

    // Validate phone number format
    if (!this.isValidPhoneNumber(phoneNumber)) {
      throw new Error('Invalid phone number');
    }

    // Check for existing pending invites
    const existing = await db.query(
      `SELECT id FROM invite_codes 
       WHERE phone_number = $1 AND status = 'pending' AND expires_at > NOW()`,
      [phoneNumber]
    );

    if (existing.rows.length > 0) {
      throw new Error('Pending invite already exists for this number');
    }

    // Generate unique code
    let code: string;
    let isUnique = false;
    
    while (!isUnique) {
      code = this.generateInviteCode();
      const check = await db.query(
        'SELECT id FROM invite_codes WHERE code = $1',
        [code]
      );
      isUnique = check.rows.length === 0;
    }

    // Calculate expiration
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + expiresInDays);

    // Insert invite
    const result = await db.query(
      `INSERT INTO invite_codes (code, phone_number, created_by, expires_at)
       VALUES ($1, $2, $3, $4)
       RETURNING *`,
      [code, phoneNumber, createdBy, expiresAt]
    );

    // Send SMS
    await smsService.sendInvite(phoneNumber, code);

    return result.rows[0];
  }

  /**
   * Validate an invite code
   */
  async validateInviteCode(code: string, phoneNumber: string) {
    const result = await db.query(
      `SELECT * FROM invite_codes
       WHERE code = $1 AND phone_number = $2 AND status = 'pending'
       AND expires_at > NOW()`,
      [code.toUpperCase(), phoneNumber]
    );

    if (result.rows.length === 0) {
      throw new Error('Invalid or expired invite code');
    }

    return result.rows[0];
  }

  /**
   * Mark invite as used
   */
  async consumeInvite(code: string, userId: string) {
    await db.query(
      `UPDATE invite_codes
       SET status = 'used', used_at = NOW(), used_by = $1
       WHERE code = $2`,
      [userId, code]
    );
  }

  private isValidPhoneNumber(phone: string): boolean {
    // E.164 format: +[country code][number]
    return /^\+[1-9]\d{1,14}$/.test(phone);
  }
}

export const inviteService = new InviteService();

OTP Generation & Verification

// services/otpService.ts
import { randomInt } from 'crypto';
import { db } from './database';
import { smsService } from './smsService';

export class OTPService {
  /**
   * Generate a 6-digit OTP
   */
  private generateOTP(): string {
    return randomInt(100000, 999999).toString();
  }

  /**
   * Send OTP to phone number
   */
  async sendOTP(phoneNumber: string) {
    // Rate limiting: max 3 OTPs per number per hour
    const recentOTPs = await db.query(
      `SELECT COUNT(*) FROM otp_tokens
       WHERE phone_number = $1 AND created_at > NOW() - INTERVAL '1 hour'`,
      [phoneNumber]
    );

    if (parseInt(recentOTPs.rows[0].count) >= 3) {
      throw new Error('Too many OTP requests. Please try again later.');
    }

    // Generate OTP
    const code = this.generateOTP();
    const expiresAt = new Date();
    expiresAt.setMinutes(expiresAt.getMinutes() + 10); // 10-minute expiry

    // Store OTP
    await db.query(
      `INSERT INTO otp_tokens (phone_number, code, expires_at)
       VALUES ($1, $2, $3)`,
      [phoneNumber, code, expiresAt]
    );

    // Send via SMS
    await smsService.sendOTP(phoneNumber, code);

    return { success: true, expiresAt };
  }

  /**
   * Verify OTP code
   */
  async verifyOTP(phoneNumber: string, code: string) {
    // Get most recent unverified OTP
    const result = await db.query(
      `SELECT * FROM otp_tokens
       WHERE phone_number = $1 AND verified_at IS NULL
       ORDER BY created_at DESC
       LIMIT 1`,
      [phoneNumber]
    );

    if (result.rows.length === 0) {
      throw new Error('No OTP found for this number');
    }

    const otp = result.rows[0];

    // Check if expired
    if (new Date() > new Date(otp.expires_at)) {
      throw new Error('OTP has expired');
    }

    // Check max attempts
    if (otp.attempts >= otp.max_attempts) {
      throw new Error('Maximum verification attempts exceeded');
    }

    // Increment attempts
    await db.query(
      `UPDATE otp_tokens SET attempts = attempts + 1 WHERE id = $1`,
      [otp.id]
    );

    // Verify code
    if (otp.code !== code) {
      throw new Error(`Invalid OTP (${otp.max_attempts - otp.attempts - 1} attempts remaining)`);
    }

    // Mark as verified
    await db.query(
      `UPDATE otp_tokens SET verified_at = NOW() WHERE id = $1`,
      [otp.id]
    );

    return { success: true, verified: true };
  }

  /**
   * Check if phone number has verified OTP recently (within 30 minutes)
   */
  async hasRecentVerification(phoneNumber: string): Promise<boolean> {
    const result = await db.query(
      `SELECT id FROM otp_tokens
       WHERE phone_number = $1 
       AND verified_at IS NOT NULL
       AND verified_at > NOW() - INTERVAL '30 minutes'`,
      [phoneNumber]
    );

    return result.rows.length > 0;
  }
}

export const otpService = new OTPService();

SMS Service (Twilio)

// services/smsService.ts
import twilio from 'twilio';

const client = twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
);

export class SMSService {
  private from = process.env.TWILIO_PHONE_NUMBER;

  async sendInvite(to: string, code: string) {
    const message = `You've been invited! Your invite code is: ${code}. Valid for 7 days. Download the app to get started!`;

    await client.messages.create({
      to,
      from: this.from,
      body: message,
    });
  }

  async sendOTP(to: string, code: string) {
    const message = `Your verification code is: ${code}. This code expires in 10 minutes. Don't share this code with anyone.`;

    await client.messages.create({
      to,
      from: this.from,
      body: message,
    });
  }
}

export const smsService = new SMSService();

API Endpoints

// routes/auth.ts
import express from 'express';
import { inviteService, otpService } from '../services';
import { createUser } from '../services/userService';

const router = express.Router();

/**
 * Step 1: Validate invite code
 */
router.post('/auth/validate-invite', async (req, res) => {
  try {
    const { inviteCode, phoneNumber } = req.body;

    // Validate invite
    await inviteService.validateInviteCode(inviteCode, phoneNumber);

    // Send OTP
    const result = await otpService.sendOTP(phoneNumber);

    res.json({
      success: true,
      message: 'OTP sent to your phone',
      expiresAt: result.expiresAt,
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message,
    });
  }
});

/**
 * Step 2: Verify OTP and create account
 */
router.post('/auth/verify-otp', async (req, res) => {
  try {
    const { inviteCode, phoneNumber, otpCode, userData } = req.body;

    // Verify OTP
    await otpService.verifyOTP(phoneNumber, otpCode);

    // Validate invite is still valid
    const invite = await inviteService.validateInviteCode(inviteCode, phoneNumber);

    // Create user account
    const user = await createUser({
      phoneNumber,
      ...userData,
    });

    // Mark invite as used
    await inviteService.consumeInvite(inviteCode, user.id);

    // Generate auth token
    const token = generateAuthToken(user);

    res.json({
      success: true,
      user,
      token,
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message,
    });
  }
});

/**
 * Resend OTP (rate limited)
 */
router.post('/auth/resend-otp', async (req, res) => {
  try {
    const { phoneNumber } = req.body;

    await otpService.sendOTP(phoneNumber);

    res.json({
      success: true,
      message: 'New OTP sent',
    });
  } catch (error) {
    res.status(429).json({
      success: false,
      error: error.message,
    });
  }
});

export default router;

React Native Frontend

Invite Code Screen

// screens/InviteCodeScreen.tsx
import React, { useState } from 'react';
import { View, TextInput, Text, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Button } from '@/components';
import { api } from '@/services/api';

export function InviteCodeScreen() {
  const navigation = useNavigation();
  const [inviteCode, setInviteCode] = useState('');
  const [phoneNumber, setPhoneNumber] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    if (!inviteCode || !phoneNumber) {
      Alert.alert('Error', 'Please enter both invite code and phone number');
      return;
    }

    setLoading(true);

    try {
      await api.post('/auth/validate-invite', {
        inviteCode: inviteCode.toUpperCase(),
        phoneNumber,
      });

      // Navigate to OTP screen
      navigation.navigate('VerifyOTP', {
        inviteCode,
        phoneNumber,
      });
    } catch (error) {
      Alert.alert('Error', error.response?.data?.error || 'Invalid invite code');
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Enter Your Invite Code</Text>
      
      <TextInput
        style={styles.input}
        placeholder="Invite Code (e.g., ABCD1234)"
        value={inviteCode}
        onChangeText={(text)=> setInviteCode(text.toUpperCase())}
        autoCapitalize="characters"
        maxLength={8}
      />

      <TextInput
        style={styles.input}
        placeholder="Phone Number (+1234567890)"
        value={phoneNumber}
        onChangeText={setPhoneNumber}
        keyboardType="phone-pad"
        autoComplete="tel"
      />

      <Button
        title="Continue"
        onPress={handleSubmit}
        loading={loading}
      />
    </View>
  );
}

OTP Verification Screen

// screens/VerifyOTPScreen.tsx
import React, { useState, useEffect } from 'react';
import { View, TextInput, Text, Alert } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { Button } from '@/components';
import { api } from '@/services/api';
import { useDispatch } from 'react-redux';
import { setAuthToken, setUser } from '@/store/slices/authSlice';

export function VerifyOTPScreen() {
  const route = useRoute();
  const navigation = useNavigation();
  const dispatch = useDispatch();
  
  const { inviteCode, phoneNumber } = route.params;
  
  const [otpCode, setOtpCode] = useState('');
  const [loading, setLoading] = useState(false);
  const [resendCooldown, setResendCooldown] = useState(0);

  useEffect(() => {
    if (resendCooldown > 0) {
      const timer = setTimeout(() => setResendCooldown(resendCooldown - 1), 1000);
      return () => clearTimeout(timer);
    }
  }, [resendCooldown]);

  const handleVerify = async () => {
    if (otpCode.length !== 6) {
      Alert.alert('Error', 'Please enter the 6-digit code');
      return;
    }

    setLoading(true);

    try {
      const response = await api.post('/auth/verify-otp', {
        inviteCode,
        phoneNumber,
        otpCode,
        userData: {
          // Collect additional user data here
        },
      });

      // Store auth token
      dispatch(setAuthToken(response.data.token));
      dispatch(setUser(response.data.user));

      // Navigate to app
      navigation.reset({
        index: 0,
        routes: [{ name: 'Home' }],
      });
    } catch (error) {
      Alert.alert('Error', error.response?.data?.error || 'Invalid OTP');
    } finally {
      setLoading(false);
    }
  };

  const handleResend = async () => {
    try {
      await api.post('/auth/resend-otp', { phoneNumber });
      Alert.alert('Success', 'New code sent!');
      setResendCooldown(60); // 60-second cooldown
    } catch (error) {
      Alert.alert('Error', error.response?.data?.error || 'Could not resend code');
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Enter Verification Code</Text>
      <Text style={styles.subtitle}>
        We sent a 6-digit code to {phoneNumber}
      </Text>

      <TextInput
        style={styles.otpInput}
        value={otpCode}
        onChangeText={setOtpCode}
        keyboardType="number-pad"
        maxLength={6}
        placeholder="000000"
      />

      <Button
        title="Verify"
        onPress={handleVerify}
        loading={loading}
      />

      <Button
        title={resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend Code'}
        onPress={handleResend}
        variant="secondary"
        disabled={resendCooldown > 0}
      />
    </View>
  );
}

Security Considerations

1. Rate Limiting

// middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';

export const otpRateLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3, // 3 requests per hour per IP
  message: 'Too many OTP requests, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

2. Invite Code Expiration

  • Automatic cleanup of expired invites
  • Configurable expiration (default 7 days)
-- Cleanup job (run daily)
DELETE FROM invite_codes
WHERE status = 'pending' AND expires_at < NOW();

3. OTP Security

  • 10-minute expiration
  • Maximum 3 attempts per code
  • Cannot reuse verified OTPs
  • Rate limiting on sending

4. Phone Number Validation

  • E.164 format enforcement
  • Prevent duplicate accounts per number

Admin Dashboard for Invite Management

// Admin API endpoint
router.post('/admin/invites/create', authenticateAdmin, async (req, res) => {
  const { phoneNumber } = req.body;
  
  const invite = await inviteService.createInvite({
    phoneNumber,
    createdBy: req.user.id,
  });

  res.json({ success: true, invite });
});

router.get('/admin/invites', authenticateAdmin, async (req, res) => {
  const invites = await db.query(`
    SELECT 
      ic.*,
      u.name as created_by_name,
      uu.name as used_by_name
    FROM invite_codes ic
    LEFT JOIN users u ON ic.created_by = u.id
    LEFT JOIN users uu ON ic.used_by = uu.id
    ORDER BY ic.created_at DESC
    LIMIT 100
  `);

  res.json({ invites: invites.rows });
});

Results

94%
Success Rate
78%
Conversion Rate
500+
Invites Sent
0
Unauthorized Signups

Security Impact

  • Zero unauthorized signups: All users verified via invite + OTP
  • Bad actor prevention: Phone verification + invite tracking
  • Community safety: Maintained women-only access

User Experience

  • Signup time: 2-3 minutes average
  • OTP delivery: 5-10 seconds average
  • Success rate: 94% first-time verification

Lessons Learned

  1. Phone verification works: Simple, secure, universally accessible
  2. Clear error messages: Users appreciated knowing exactly what went wrong
  3. Resend functionality: Critical for poor network conditions
  4. Rate limiting: Prevented abuse without hurting legitimate users
  5. Invite tracking: Valuable data for understanding growth patterns

💡Key Takeaways

  • 1Invite-only + OTP provides strong access control with zero unauthorized signups
  • 2Twilio makes SMS/OTP implementation straightforward and reliable
  • 3Rate limiting is essential - prevents abuse while allowing legitimate use
  • 4Clear error messages and UX reduce support burden significantly
  • 5Admin tools for invite tracking and management save hours of manual work
  • 694% first-time success rate proves the system is user-friendly
  • 778% conversion rate from invites to accounts shows effective onboarding