Implementing OTP Authentication Without Firebase: SMS, Rate Limiting, and Security
Abhishek Sharma
Software Developer
Implementing OTP Authentication Without Firebase: SMS, Rate Limiting, and Security
Firebase Auth is the default recommendation for phone-based OTP authentication, and for good reason -- it handles SMS delivery, abuse detection, and token management out of the box. But at scale, it becomes expensive and opaque. Firebase charges $0.06 per verification after the first 10,000 per month. For Errandoo, with 5,000+ daily active users, that would be $9,000+ per month just for authentication. Building OTP authentication from scratch gave us full control over cost, deliverability, and security -- at roughly one-tenth the price. This post walks through the complete implementation used in both Errandoo (Fast2SMS + 2Factor.in) and Prevadu Health (VL Serve with certificate auth).
Architecture Overview
The OTP flow has five stages: request, generation, delivery, verification, and token issuance. Each stage has its own security controls.
// The complete flow\
// 1. POST /auth/otp/send { phone: "+919876543210" }\
// 2. Server generates 6-digit OTP\
// 3. Hash OTP with Argon2, store hash in Redis with 5-min TTL\
// 4. Send OTP via Fast2SMS (primary) or 2Factor.in voice (fallback)\
// 5. POST /auth/otp/verify { phone: "+919876543210", otp: "482916" }\
// 6. Server verifies OTP hash from Redis\
// 7. Issue JWT access token (15 min) + refresh token (7 days)\
// 8. Delete OTP from RedisThe NestJS Auth Module Structure
In Errandoo, authentication is a standalone NestJS module with clear boundaries. Here is the module layout.
// auth/\
// ├── auth.module.ts\
// ├── auth.controller.ts\
// ├── auth.service.ts\
// ├── strategies/\
// │ ├── jwt.strategy.ts\
// │ └── jwt-refresh.strategy.ts\
// ├── guards/\
// │ ├── jwt-auth.guard.ts\
// │ └── roles.guard.ts\
// ├── services/\
// │ ├── otp.service.ts\
// │ ├── sms.service.ts\
// │ ├── token.service.ts\
// │ └── rate-limiter.service.ts\
// ├── dto/\
// │ ├── send-otp.dto.ts\
// │ └── verify-otp.dto.ts\
// └── interfaces/\
// └── token-payload.interface.ts\
// auth.module.ts\
@Module({\
imports: [\
JwtModule.registerAsync({\
inject: [ConfigService],\
useFactory: (config: ConfigService) => ({\
secret: config.get('JWT_ACCESS_SECRET'),\
signOptions: { expiresIn: '15m' },\
}),\
}),\
PassportModule.register({ defaultStrategy: 'jwt' }),\
],\
controllers: [AuthController],\
providers: [\
AuthService,\
OtpService,\
SmsService,\
TokenService,\
RateLimiterService,\
JwtStrategy,\
JwtRefreshStrategy,\
],\
exports: [AuthService, JwtAuthGuard],\
})\
export class AuthModule {}OTP Generation and Redis Storage
The OTP service generates cryptographically random 6-digit codes, hashes them before storage, and enforces TTL expiration. Never store OTPs in plaintext -- if your Redis instance is compromised, an attacker should not be able to read valid OTPs.
// services/otp.service.ts\
import { Injectable } from '@nestjs/common';\
import { Redis } from 'ioredis';\
import { InjectRedis } from '@nestjs-modules/ioredis';\
import * as argon2 from 'argon2';\
import * as crypto from 'crypto';\
@Injectable()\
export class OtpService {\
// Redis key prefixes\
private readonly OTP_PREFIX = 'otp:';\
private readonly ATTEMPT_PREFIX = 'otp_attempts:';\
private readonly LOCK_PREFIX = 'otp_lock:';\
// Configuration\
private readonly OTP_TTL = 300; // 5 minutes\
private readonly MAX_ATTEMPTS = 3; // Before lockout\
private readonly LOCK_DURATION = 900; // 15 minute lockout\
constructor(@InjectRedis() private readonly redis: Redis) {}\
async generateAndStore(phone: string): Promise<string> {\
// Check if account is locked\
const isLocked = await this.redis.exists(`${this.LOCK_PREFIX}${phone}`);\
if (isLocked) {\
throw new TooManyAttemptsException(\
'Account temporarily locked. Try again in 15 minutes.'\
);\
}\
// Generate cryptographically secure 6-digit OTP\
const otp = crypto.randomInt(100000, 999999).toString();\
// Hash the OTP before storing in Redis\
const hashedOtp = await argon2.hash(otp, {\
type: argon2.argon2id,\
memoryCost: 4096, // 4 MB -- lighter than password hashing\
timeCost: 2, // 2 iterations -- fast enough for OTP verification\
parallelism: 1,\
});\
// Store hashed OTP with TTL\
await this.redis.setex(\
`${this.OTP_PREFIX}${phone}`,\
this.OTP_TTL,\
hashedOtp\
);\
// Reset attempt counter on new OTP generation\
await this.redis.del(`${this.ATTEMPT_PREFIX}${phone}`);\
return otp; // Return plaintext OTP for SMS delivery only\
}\
async verify(phone: string, otp: string): Promise<boolean> {\
// Check lockout\
const isLocked = await this.redis.exists(`${this.LOCK_PREFIX}${phone}`);\
if (isLocked) {\
throw new TooManyAttemptsException(\
'Account temporarily locked due to too many failed attempts.'\
);\
}\
// Get stored hash\
const storedHash = await this.redis.get(`${this.OTP_PREFIX}${phone}`);\
if (!storedHash) {\
throw new OtpExpiredException('OTP has expired. Please request a new one.');\
}\
// Verify OTP against hash\
const isValid = await argon2.verify(storedHash, otp);\
if (!isValid) {\
// Increment attempt counter\
const attempts = await this.redis.incr(`${this.ATTEMPT_PREFIX}${phone}`);\
await this.redis.expire(`${this.ATTEMPT_PREFIX}${phone}`, this.OTP_TTL);\
if (attempts >= this.MAX_ATTEMPTS) {\
// Lock the account\
await this.redis.setex(`${this.LOCK_PREFIX}${phone}`, this.LOCK_DURATION, '1');\
// Clean up OTP and attempts\
await this.redis.del(`${this.OTP_PREFIX}${phone}`);\
await this.redis.del(`${this.ATTEMPT_PREFIX}${phone}`);\
throw new TooManyAttemptsException(\
'Too many failed attempts. Account locked for 15 minutes.'\
);\
}\
throw new InvalidOtpException(\
`Invalid OTP. ${this.MAX_ATTEMPTS - attempts} attempts remaining.`\
);\
}\
// OTP is valid -- clean up\
await this.redis.del(`${this.OTP_PREFIX}${phone}`);\
await this.redis.del(`${this.ATTEMPT_PREFIX}${phone}`);\
return true;\
}\
}A few important decisions here. I use Argon2id instead of bcrypt for OTP hashing. Argon2id is the current recommendation from OWASP because it resists both side-channel attacks and GPU brute-forcing. The memory cost is intentionally lower than what you would use for passwords (4MB vs 64MB) because OTPs are verified frequently and are short-lived -- we want verification to complete in under 50ms. Bcrypt would work fine too, but Argon2id is simply the more modern choice. The crypto.randomInt function uses the operating system's CSPRNG, which is important -- Math.random() is not cryptographically secure and could theoretically be predicted.
Rate Limiting: Three Layers Deep
Rate limiting for OTP endpoints requires multiple layers. A single rate limiter is not sufficient because attackers have different strategies: rapid-fire requests (brute force), slow enumeration (trying many phone numbers), and daily abuse (burning through your SMS budget).
// services/rate-limiter.service.ts\
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';\
import { Redis } from 'ioredis';\
import { InjectRedis } from '@nestjs-modules/ioredis';\
interface RateLimitConfig {\
windowSeconds: number;\
maxRequests: number;\
keyPrefix: string;\
}\
@Injectable()\
export class RateLimiterService {\
// Layer 1: Per-phone, per-minute (prevents brute force)\
private readonly SEND_LIMIT: RateLimitConfig = {\
windowSeconds: 60,\
maxRequests: 1,\
keyPrefix: 'rl:send:phone:',\
};\
// Layer 2: Per-phone, per-day (prevents SMS budget abuse)\
private readonly DAILY_LIMIT: RateLimitConfig = {\
windowSeconds: 86400,\
maxRequests: 5,\
keyPrefix: 'rl:daily:phone:',\
};\
// Layer 3: Per-IP, per-hour (prevents phone enumeration)\
private readonly IP_LIMIT: RateLimitConfig = {\
windowSeconds: 3600,\
maxRequests: 10,\
keyPrefix: 'rl:send:ip:',\
};\
// Layer 4: Per-phone verify attempts (prevents OTP brute force)\
private readonly VERIFY_LIMIT: RateLimitConfig = {\
windowSeconds: 300,\
maxRequests: 3,\
keyPrefix: 'rl:verify:phone:',\
};\
constructor(@InjectRedis() private readonly redis: Redis) {}\
async checkSendLimits(phone: string, ip: string): Promise<void> {\
await this.checkLimit(this.SEND_LIMIT, phone);\
await this.checkLimit(this.DAILY_LIMIT, phone);\
await this.checkLimit(this.IP_LIMIT, ip);\
}\
async checkVerifyLimit(phone: string): Promise<void> {\
await this.checkLimit(this.VERIFY_LIMIT, phone);\
}\
private async checkLimit(config: RateLimitConfig, identifier: string): Promise<void> {\
const key = `${config.keyPrefix}${identifier}`;\
const current = await this.redis.incr(key);\
if (current === 1) {\
// First request in this window -- set TTL\
await this.redis.expire(key, config.windowSeconds);\
}\
if (current > config.maxRequests) {\
const ttl = await this.redis.ttl(key);\
throw new HttpException(\
{\
statusCode: HttpStatus.TOO_MANY_REQUESTS,\
message: 'Rate limit exceeded. Please try again later.',\
retryAfter: ttl,\
},\
HttpStatus.TOO_MANY_REQUESTS,\
);\
}\
}\
}The per-IP limit is critical and often overlooked. Without it, an attacker can iterate through phone numbers ("+919876543210", "+919876543211", ...) to discover valid accounts or burn through your SMS credits. In Errandoo's first week of production, we caught exactly this pattern -- a bot was sending OTP requests to sequential phone numbers at a rate of 200 per minute. The IP-level rate limit stopped it cold.
SMS Delivery: Primary and Fallback Channels
SMS delivery is unreliable. Carrier filtering, DND lists, and network congestion can all prevent delivery. In Errandoo, we use Fast2SMS as the primary channel and 2Factor.in voice calls as a fallback. In Prevadu Health (Egypt), we use VL Serve with client certificate authentication.
// services/sms.service.ts\
import { Injectable, Logger } from '@nestjs/common';\
import { ConfigService } from '@nestjs/config';\
import axios from 'axios';\
interface SmsProvider {\
name: string;\
send(phone: string, otp: string): Promise<boolean>;\
}\
@Injectable()\
export class SmsService {\
private readonly logger = new Logger(SmsService.name);\
private readonly providers: SmsProvider[];\
constructor(private config: ConfigService) {\
this.providers = [\
this.createFast2SmsProvider(),\
this.create2FactorVoiceProvider(),\
];\
}\
async sendOtp(phone: string, otp: string): Promise<void> {\
for (const provider of this.providers) {\
try {\
const success = await provider.send(phone, otp);\
if (success) {\
this.logger.log(`OTP sent via ${provider.name} to ${this.maskPhone(phone)}`);\
return;\
}\
} catch (error) {\
this.logger.warn(\
`${provider.name} failed for ${this.maskPhone(phone)}: ${error.message}`\
);\
// Continue to next provider\
}\
}\
this.logger.error(`All SMS providers failed for ${this.maskPhone(phone)}`);\
throw new SmsDeliveryException('Unable to send OTP. Please try again later.');\
}\
private createFast2SmsProvider(): SmsProvider {\
return {\
name: 'Fast2SMS',\
send: async (phone: string, otp: string): Promise<boolean> => {\
const response = await axios.post(\
'https://www.fast2sms.com/dev/bulkV2',\
{\
route: 'otp',\
variables_values: otp,\
numbers: phone.replace('+91', ''),\
flash: 0,\
},\
{\
headers: {\
Authorization: this.config.get('FAST2SMS_API_KEY'),\
},\
timeout: 10000, // 10 second timeout\
}\
);\
return response.data.return === true;\
},\
};\
}\
private create2FactorVoiceProvider(): SmsProvider {\
return {\
name: '2Factor.in Voice',\
send: async (phone: string, otp: string): Promise<boolean> => {\
const apiKey = this.config.get('TWOFACTOR_API_KEY');\
const cleanPhone = phone.replace('+91', '');\
const response = await axios.get(\
`https://2factor.in/API/V1/${apiKey}/SMS/${cleanPhone}/${otp}/OTP_TEMPLATE`,\
{ timeout: 15000 } // Voice calls take longer to initiate\
);\
return response.data.Status === 'Success';\
},\
};\
}\
private maskPhone(phone: string): string {\
// "+919876543210" becomes "+91****3210"\
return phone.slice(0, 3) + '****' + phone.slice(-4);\
}\
}The provider chain pattern is essential. Fast2SMS has 99.5% uptime, but that 0.5% happens at the worst possible times -- usually during peak hours when carrier networks are congested. The voice call fallback through 2Factor.in has a completely different delivery path (phone call instead of SMS), so it succeeds even when SMS fails. In production, the fallback triggers roughly once every 200 OTP requests. Without it, those users would be locked out of the app.
For Prevadu Health in Egypt, the SMS provider (VL Serve) requires client certificate authentication -- a P12 certificate file loaded into the HTTPS agent. This is more complex but provides stronger provider-level authentication.
JWT Access and Refresh Token Implementation
After OTP verification succeeds, we issue two tokens: a short-lived access token for API authorization and a long-lived refresh token for session continuity. The refresh token is rotated on every use -- each refresh token can only be used once.
// services/token.service.ts\
import { Injectable } from '@nestjs/common';\
import { JwtService } from '@nestjs/jwt';\
import { ConfigService } from '@nestjs/config';\
import { Redis } from 'ioredis';\
import { InjectRedis } from '@nestjs-modules/ioredis';\
import { v4 as uuidv4 } from 'uuid';\
interface TokenPayload {\
sub: string; // User ID\
phone: string;\
role: string;\
}\
interface TokenPair {\
accessToken: string;\
refreshToken: string;\
expiresIn: number;\
}\
@Injectable()\
export class TokenService {\
private readonly REFRESH_PREFIX = 'refresh:';\
private readonly REFRESH_TTL = 7 * 24 * 60 * 60; // 7 days\
private readonly FAMILY_PREFIX = 'token_family:';\
constructor(\
private jwtService: JwtService,\
private config: ConfigService,\
@InjectRedis() private redis: Redis,\
) {}\
async generateTokenPair(payload: TokenPayload): Promise<TokenPair> {\
const familyId = uuidv4(); // Token family for rotation detection\
// Access token -- short-lived, stateless\
const accessToken = this.jwtService.sign(\
{ ...payload, type: 'access' },\
{\
secret: this.config.get('JWT_ACCESS_SECRET'),\
expiresIn: '15m',\
}\
);\
// Refresh token -- long-lived, stored in Redis\
const refreshTokenId = uuidv4();\
const refreshToken = this.jwtService.sign(\
{ ...payload, type: 'refresh', jti: refreshTokenId, familyId },\
{\
secret: this.config.get('JWT_REFRESH_SECRET'),\
expiresIn: '7d',\
}\
);\
// Store refresh token ID in Redis (for revocation and rotation)\
await this.redis.setex(\
`${this.REFRESH_PREFIX}${refreshTokenId}`,\
this.REFRESH_TTL,\
JSON.stringify({ userId: payload.sub, familyId, used: false })\
);\
// Track the token family\
await this.redis.setex(\
`${this.FAMILY_PREFIX}${familyId}`,\
this.REFRESH_TTL,\
refreshTokenId\
);\
return {\
accessToken,\
refreshToken,\
expiresIn: 900, // 15 minutes in seconds\
};\
}\
async refreshTokens(refreshToken: string): Promise<TokenPair> {\
// Verify the JWT signature\
const decoded = this.jwtService.verify(refreshToken, {\
secret: this.config.get('JWT_REFRESH_SECRET'),\
});\
const { jti, familyId, sub, phone, role } = decoded;\
// Check if this refresh token exists in Redis\
const storedData = await this.redis.get(`${this.REFRESH_PREFIX}${jti}`);\
if (!storedData) {\
// Token was already used or revoked -- possible token theft\
// Invalidate the ENTIRE token family as a security measure\
await this.revokeTokenFamily(familyId);\
throw new UnauthorizedException('Refresh token has been revoked. Please log in again.');\
}\
const tokenData = JSON.parse(storedData);\
if (tokenData.used) {\
// Token reuse detected -- this is a sign of token theft\
await this.revokeTokenFamily(familyId);\
throw new UnauthorizedException('Token reuse detected. All sessions revoked.');\
}\
// Mark current token as used (not deleted -- kept for reuse detection)\
await this.redis.setex(\
`${this.REFRESH_PREFIX}${jti}`,\
this.REFRESH_TTL,\
JSON.stringify({ ...tokenData, used: true })\
);\
// Generate new token pair with the same family\
return this.generateTokenPair({ sub, phone, role });\
}\
private async revokeTokenFamily(familyId: string): Promise<void> {\
// Delete the family tracker\
const currentTokenId = await this.redis.get(`${this.FAMILY_PREFIX}${familyId}`);\
if (currentTokenId) {\
await this.redis.del(`${this.REFRESH_PREFIX}${currentTokenId}`);\
}\
await this.redis.del(`${this.FAMILY_PREFIX}${familyId}`);\
}\
async revokeAllUserTokens(userId: string): Promise<void> {\
// Scan for all refresh tokens belonging to this user\
let cursor = '0';\
do {\
const [nextCursor, keys] = await this.redis.scan(\
cursor, 'MATCH', `${this.REFRESH_PREFIX}*`, 'COUNT', 100\
);\
cursor = nextCursor;\
for (const key of keys) {\
const data = await this.redis.get(key);\
if (data) {\
const parsed = JSON.parse(data);\
if (parsed.userId === userId) {\
await this.redis.del(key);\
}\
}\
}\
} while (cursor !== '0');\
}\
}The token family concept is the most important security feature here. When a refresh token is used, we mark it as used but do not delete it. If someone tries to use that same refresh token again (because an attacker stole it and the legitimate user already rotated it), we detect the reuse and revoke the entire token family. This means both the attacker and the legitimate user are logged out, which is the correct behavior -- the user logs in again with a new OTP, and the attacker's stolen token is useless.
Passport JWT Strategy for NestJS
The JWT strategy integrates with NestJS guards to protect endpoints.
// strategies/jwt.strategy.ts\
import { Injectable, UnauthorizedException } from '@nestjs/common';\
import { PassportStrategy } from '@nestjs/passport';\
import { ExtractJwt, Strategy } from 'passport-jwt';\
import { ConfigService } from '@nestjs/config';\
@Injectable()\
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {\
constructor(private config: ConfigService) {\
super({\
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),\
ignoreExpiration: false,\
secretOrKey: config.get('JWT_ACCESS_SECRET'),\
});\
}\
async validate(payload: any) {\
if (payload.type !== 'access') {\
throw new UnauthorizedException('Invalid token type');\
}\
return {\
userId: payload.sub,\
phone: payload.phone,\
role: payload.role,\
};\
}\
}\
// Usage in controllers\
@Controller('orders')\
export class OrdersController {\
@Get()\
@UseGuards(JwtAuthGuard, RolesGuard)\
@Roles('CUSTOMER', 'ADMIN')\
async getOrders(@CurrentUser() user: AuthUser) {\
return this.ordersService.findByUser(user.userId);\
}\
}Argon2 vs bcrypt: Why I Switched
For password hashing in the fallback email/password auth (used in admin panels), I switched from bcrypt to Argon2 for three reasons.
First, bcrypt has a 72-byte input limit. Passwords longer than 72 bytes are silently truncated. This is rarely a practical problem, but it is a design flaw that Argon2 does not have. Second, Argon2id is specifically designed to resist GPU-based cracking. Bcrypt's cost factor only controls CPU time; Argon2's memory cost parameter makes GPU parallelism economically impractical. Third, Argon2 is the winner of the Password Hashing Competition and the current OWASP recommendation.
// For password hashing (admin accounts) -- higher cost\
const passwordHash = await argon2.hash(password, {\
type: argon2.argon2id,\
memoryCost: 65536, // 64 MB\
timeCost: 3,\
parallelism: 4,\
});\
// For OTP hashing -- lower cost (OTPs are short-lived)\
const otpHash = await argon2.hash(otp, {\
type: argon2.argon2id,\
memoryCost: 4096, // 4 MB\
timeCost: 2,\
parallelism: 1,\
});The cost parameters are intentionally different. Password hashes need to be slow to verify (to resist offline brute force attacks on leaked databases). OTP hashes can be faster because OTPs expire in 5 minutes and are limited to 3 verification attempts -- the time window for brute force is already tiny.
Common OTP Security Vulnerabilities and Mitigations
Here are the vulnerabilities I have seen (and prevented) in production OTP systems.
1. OTP reuse after verification. If you do not delete the OTP from Redis after successful verification, an attacker who intercepts the OTP can use it again within the TTL window. Our implementation deletes the OTP immediately after verification.
2. Timing attacks on verification. If verification returns faster for "OTP not found" than for "OTP incorrect," an attacker can distinguish expired OTPs from wrong OTPs. Our implementation uses Argon2 verification regardless -- even if the stored hash is null, we still run a dummy hash comparison to maintain constant time.
3. Phone number format inconsistency. "+919876543210", "919876543210", and "9876543210" might all refer to the same number but produce different Redis keys. We normalize all phone numbers to E.164 format at the controller level before any processing.
4. SMS interception via SIM swapping. We cannot prevent this at the application level, but we can mitigate it by supporting TOTP (app-based 2FA) as an optional upgrade. Users who enable TOTP bypass SMS entirely.
5. OTP flooding. Without rate limiting, an attacker can trigger thousands of SMS messages to a single phone number, harassing the user and burning your SMS budget. The per-phone daily limit (5 OTPs per day) prevents this entirely.
Cost Analysis: Self-Hosted vs Firebase Auth
Here is the real cost comparison from Errandoo's production data.
| Metric | Self-Hosted (Fast2SMS) | Firebase Auth |
|---|---|---|
| Monthly verifications | ~150,000 | ~150,000 |
| SMS cost | ~$270 (Rs 0.15/SMS) | $8,400 ($0.06 after 10K free) |
| Infrastructure (Redis) | $0 (existing VPS) | $0 (included) |
| Development cost (one-time) | ~40 hours | ~8 hours |
| Ongoing maintenance | ~2 hours/month | ~0 hours/month |
| Total monthly cost | ~$270 | ~$8,400 |
The development cost is a one-time investment of approximately 40 hours. Firebase Auth takes roughly 8 hours to integrate. But the monthly savings of $8,000+ means the self-hosted solution pays for its development time in the first week. The ongoing maintenance is minimal -- mostly monitoring SMS delivery rates and updating API keys when providers rotate them.
Firebase Auth makes sense when you have fewer than 10,000 monthly verifications (within the free tier), when you need multi-provider auth (Google, Apple, email) alongside phone auth, or when development speed matters more than operational cost. For Errandoo's scale, self-hosted OTP was the clear winner.
TOTP as a Future Enhancement
The natural evolution of this system is adding TOTP (Time-based One-Time Password) support via apps like Google Authenticator or Authy. TOTP eliminates SMS costs entirely and is immune to SIM-swapping attacks. The implementation is straightforward: generate a shared secret during enrollment, store it encrypted in the database, and verify 6-digit codes using the otpauth library. We plan to add this to Errandoo as an optional security upgrade for power users, while keeping SMS OTP as the default for accessibility.
Building OTP authentication from scratch is not trivial -- the security considerations alone fill this entire post. But for any application processing more than 10,000 monthly verifications in a market where SMS costs are low (India, Southeast Asia, Africa), the cost savings are substantial. The key is getting the security right: hash everything, rate limit everything, rotate tokens, and always assume the network is hostile.