The Problem
Building a women-only community app required strict access control. Traditional email/password signup wouldn't work—we needed to:
- Verify eligibility before allowing signup
- Prevent fake accounts and bad actors
- Scale invite distribution without manual approval
- 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
- Phone verification works: Simple, secure, universally accessible
- Clear error messages: Users appreciated knowing exactly what went wrong
- Resend functionality: Critical for poor network conditions
- Rate limiting: Prevented abuse without hurting legitimate users
- 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
