Email Verification System#
ContainerPub implements a comprehensive email OTP (One-Time Password) verification system to ensure user email authenticity after registration and first login.
Overview#
The email verification system provides:
- OTP Generation - Secure 6-digit codes with configurable expiry
- Rate Limiting - Prevent brute force and spam attempts
- Resend Functionality - Allow users to request new codes
- Status Tracking - Track verification status per user
- Email Integration - Automated email delivery via configured provider
- Cooldown Management - Prevent rapid resend requests
Architecture#
Components#
- EmailVerificationService - Core business logic
- EmailVerificationMiddleware - Rate limiting and request validation
- EmailVerificationLimiter - Cooldown and attempt tracking
- Database Layer - User verification status storage
- Email Provider - External email delivery service
Data Flow#
User Registration/Login
↓
Check Email Verification Status
↓
If Not Verified:
├→ Generate OTP
├→ Store in Database
├→ Send via Email
└→ Return Status
↓
User Submits OTP
↓
Validate OTP
├→ Check Expiry
├→ Check Attempts
└→ Verify Code
↓
Mark Email as Verified
↓
Return Success
API Endpoints#
Send Verification OTP#
POST /api/email-verification/send
Authorization: Bearer <access_token>
Content-Type: application/json
Request:
{}
Response (Success):
{
"message": "OTP sent to your email",
"email": "user@example.com",
"expiresIn": 300
}
Response (Already Verified):
{
"message": "Email already verified",
"isVerified": true
}
Error Responses:
429 Too Many Requests- Rate limited (cooldown active)401 Unauthorized- Invalid or expired token500 Internal Server Error- Email service failure
Verify OTP Code#
POST /api/email-verification/verify
Authorization: Bearer <access_token>
Content-Type: application/json
{
"otp": "123456"
}
Response (Success):
{
"message": "Email verified successfully",
"isVerified": true
}
Response (Invalid OTP):
{
"error": "Invalid OTP code",
"attemptsRemaining": 2
}
Error Responses:
400 Bad Request- Invalid OTP format or expired401 Unauthorized- Invalid token429 Too Many Requests- Too many failed attempts
Resend Verification OTP#
POST /api/email-verification/resend
Authorization: Bearer <access_token>
Content-Type: application/json
Request:
{}
Response:
{
"message": "OTP resent to your email",
"expiresIn": 300,
"cooldownRemaining": 0
}
Error Responses:
429 Too Many Requests- Resend cooldown active401 Unauthorized- Invalid token
Check Verification Status#
GET /api/email-verification/status
Authorization: Bearer <access_token>
Response:
{
"isVerified": false,
"email": "user@example.com",
"lastOtpSentAt": "2024-01-17T10:30:00Z",
"otpExpiresAt": "2024-01-17T10:35:00Z"
}
Rate Limiting Strategy#
OTP Send Limits#
- Initial Send: Allowed immediately after login
- Resend Cooldown: 60 seconds between resend requests
- Max Resends: 5 per hour per user
- Daily Limit: 10 OTP sends per day
Verification Attempt Limits#
- Max Attempts: 5 failed attempts per OTP
- Lockout Duration: 15 minutes after max attempts
- Cooldown Between Attempts: 2 seconds
Implementation#
class EmailVerificationLimiter {
// Track OTP send attempts
Future<bool> canSendOtp(String userId) async {
final key = 'otp_send_$userId';
final attempts = await _getAttempts(key);
if (attempts >= 5) {
final lastAttempt = await _getLastAttemptTime(key);
final hourAgo = DateTime.now().subtract(Duration(hours: 1));
if (lastAttempt.isAfter(hourAgo)) {
return false; // Rate limited
}
}
return true;
}
// Track verification attempts
Future<bool> canVerifyOtp(String userId, String otpId) async {
final key = 'verify_attempts_$otpId';
final attempts = await _getAttempts(key);
if (attempts >= 5) {
return false; // Too many attempts
}
return true;
}
// Get cooldown time remaining
Future<int> getResendCooldown(String userId) async {
final key = 'otp_resend_$userId';
final lastSent = await _getLastAttemptTime(key);
final cooldownEnd = lastSent.add(Duration(seconds: 60));
final remaining = cooldownEnd.difference(DateTime.now()).inSeconds;
return remaining > 0 ? remaining : 0;
}
}
OTP Generation & Storage#
OTP Code Generation#
class OtpService {
static const int otpLength = 6;
static const Duration otpValidity = Duration(hours: 24);
/// Generate 6-digit OTP code using secure random
static String generateOtp() {
final timestamp = DateTime.now().microsecondsSinceEpoch;
final random = Random.secure();
final seed = timestamp + random.nextInt(999999);
final otp = (seed % 1000000).toString().padLeft(6, '0');
return otp;
}
/// Generate complete OTP with hash and salt
static OtpResult generateOtpWithHash({
required String email,
DateTime? timestamp,
}) {
final createdAt = timestamp ?? DateTime.now();
final otp = generateOtp();
final salt = generateSalt(
email: email,
timestamp: createdAt,
);
final hash = hashOtp(
otp: otp,
salt: salt,
);
return OtpResult(
otp: otp,
hash: hash,
salt: salt,
createdAt: createdAt,
);
}
}
Database Storage#
Table: email_verification_otps
| Column | Type | Description |
|---|---|---|
id | SERIAL | Primary key |
user_uuid | VARCHAR | Foreign key to users (UUID) |
otp_hash | VARCHAR | HMAC-SHA256 hash of OTP |
salt | TEXT | Base64 encoded salt (timestamp:email) |
created_at |
TIMESTAMP | Creation time (exact timestamp used for hashing) |
Important: The created_at timestamp must match the timestamp used in the salt generation to ensure hash verification works correctly.
Table: email_verifications
| Column | Type | Description |
|---|---|---|
user_id | UUID | Primary key (foreign key) |
email | VARCHAR | User's email address |
is_verified | BOOLEAN | Verification status |
verified_at | TIMESTAMP | Verification completion time |
last_otp_sent_at | TIMESTAMP | Last OTP send time |
OTP Hashing with HMAC-SHA256#
import 'dart:convert';
import 'package:crypto/crypto.dart';
class OtpService {
/// Generate base64 encoded salt from timestamp and email
static String generateSalt({
required String email,
required DateTime timestamp,
}) {
final plainSalt = '${timestamp.microsecondsSinceEpoch}:$email';
return base64Encode(utf8.encode(plainSalt));
}
/// Hash OTP using HMAC-SHA256 with salt
static String hashOtp({
required String otp,
required String salt,
}) {
final hmac = Hmac(sha256, utf8.encode(salt));
final digest = hmac.convert(utf8.encode(otp));
return digest.toString();
}
/// Verify OTP against stored hash
static bool verifyOtp({
required String otp,
required String storedHash,
required String storedSalt,
}) {
final computedHash = hashOtp(
otp: otp,
salt: storedSalt,
);
return computedHash == storedHash;
}
}
Why HMAC-SHA256 with Salt?
- Never store plain text codes - OTP is hashed before storage
- Prevent database breach exposure - Hash is one-way transformation
- Unique salts per OTP - Combines timestamp and email (base64 encoded)
- HMAC security - Keyed hash prevents rainbow table attacks
- Timestamp consistency - Same timestamp used for salt generation and storage
Critical Fix (January 2026):
Previously, the system had a hash mismatch issue where:
- OTP was generated with timestamp
T1 -
Database stored
created_atwith timestampT2(fromCURRENT_TIMESTAMP) - Verification failed because salt was based on
T1but retrievedT2
Solution: Explicitly pass the same timestamp to both salt generation and database storage:
final timestamp = DateTime.now();
final otpResult = OtpService.generateOtpWithHash(
email: email,
timestamp: timestamp, // Use explicit timestamp
);
// Store with EXACT same timestamp
await DatabaseManagers.emailVerificationOtps.insert(
EmailVerificationOtpEntity(
userUuid: userUuid,
otpHash: otpResult.hash,
salt: otpResult.salt,
createdAt: timestamp, // Same timestamp as salt
).toDBMap(),
);
Email Service Integration#
Email Template#
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
body {
font-family: Arial, sans-serif;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.header {
background: #007bff;
color: white;
padding: 20px;
}
.content {
padding: 20px;
}
.otp-code {
font-size: 32px;
font-weight: bold;
letter-spacing: 5px;
text-align: center;
margin: 20px 0;
}
.footer {
color: #666;
font-size: 12px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Email Verification</h1>
</div>
<div class="content">
<p>Hi ,</p>
<p>Your verification code is:</p>
<div class="otp-code"></div>
<p>This code expires in 5 minutes.</p>
<p>If you didn't request this code, please ignore this email.</p>
</div>
<div class="footer">
<p>© 2024 ContainerPub. All rights reserved.</p>
</div>
</div>
</body>
</html>
Email Service Implementation#
class EmailService {
final String _provider; // 'sendgrid', 'mailgun', etc.
final String _apiKey;
EmailService({required String provider, required String apiKey})
: _provider = provider,
_apiKey = apiKey;
Future<void> sendVerificationEmail({
required String email,
required String userName,
required String otpCode,
}) async {
final emailContent = _buildEmailContent(userName, otpCode);
switch (_provider) {
case 'sendgrid':
await _sendViaSendGrid(email, emailContent);
break;
case 'mailgun':
await _sendViaMailgun(email, emailContent);
break;
default:
throw Exception('Unknown email provider: $_provider');
}
}
String _buildEmailContent(String userName, String otpCode) {
// Build HTML email with template
return '''
<h1>Email Verification</h1>
<p>Hi $userName,</p>
<p>Your verification code is:</p>
<div style="font-size: 32px; font-weight: bold;">$otpCode</div>
<p>This code expires in 5 minutes.</p>
''';
}
}
Integration with Authentication#
Login Response Update#
The login endpoint now includes email verification status:
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "base64EncodedPassword"
}
Response:
{
"accessToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"isEmailVerified": false,
"userEmail": "user@example.com"
}
Auto-Send OTP on First Login#
class AuthHandler {
Future<Response> login(Request request) async {
// ... existing login logic ...
final loginResponse = LoginResponse(
accessToken: accessToken,
refreshToken: refreshToken,
isEmailVerified: user.isEmailVerified,
userEmail: user.email,
);
// Auto-send OTP if not verified
if (!user.isEmailVerified) {
await emailVerificationService.sendOtp(user.id);
}
return Response.ok(jsonEncode(loginResponse.toJson()));
}
}
Error Handling#
Common Error Scenarios#
| Scenario | Status | Response |
|---|---|---|
| OTP expired | 400 | {"error": "OTP expired", "message": "Please request a new code"} |
| Invalid OTP format | 400 | {"error": "Invalid OTP format", "message": "OTP must be 6 digits"} |
| Too many attempts | 429 | {"error": "Too many attempts", "cooldownMinutes": 15} |
| Rate limited | 429 | {"error": "Rate limited", "retryAfterSeconds": 60} |
| Already verified | 400 | {"error": "Email already verified"} |
| User not found | 404 | {"error": "User not found"} |
Error Response Format#
{
"error": "error_code",
"message": "Human-readable message",
"details": {
"attemptsRemaining": 2,
"cooldownSeconds": 45
}
}
Security Considerations#
OTP Security#
- 6-Digit Codes - 1 million possible combinations
- 24-Hour Expiry - Configurable validity period
- Attempt Limiting - 5 attempts per OTP
- HMAC-SHA256 Hashing - Strong cryptographic hash with salt
- Base64 Encoded Salt - Obfuscates timestamp and email
- Unique Salts - Each OTP has unique salt (timestamp:email)
- Secure Generation - Use
Random.secure()with timestamp seed - Timestamp Consistency - Prevents hash mismatch issues
Rate Limiting#
- Per-User Limits - Prevent individual abuse
- Cooldown Timers - Space out requests
- Exponential Backoff - Increase penalties
- IP-Based Limits - Optional additional layer
Email Delivery#
- HTTPS Only - Secure transmission
- Signed Emails - Optional DKIM/SPF
- No Sensitive Data - Don't include passwords
- Audit Logging - Track all sends
Monitoring & Logging#
Key Metrics#
class EmailVerificationMetrics {
// Track OTP sends
void recordOtpSent(String userId) {
_metrics.increment('email_verification.otp_sent');
}
// Track successful verifications
void recordVerificationSuccess(String userId) {
_metrics.increment('email_verification.success');
}
// Track failed attempts
void recordVerificationFailure(String userId) {
_metrics.increment('email_verification.failure');
}
// Track rate limit hits
void recordRateLimitHit(String userId) {
_metrics.increment('email_verification.rate_limited');
}
}
Logging#
// Log OTP send
logger.info('OTP sent to user', {
'userId': userId,
'email': email,
'timestamp': DateTime.now(),
});
// Log verification attempt
logger.info('Verification attempt', {
'userId': userId,
'success': true,
'timestamp': DateTime.now(),
});
// Log rate limit
logger.warn('Rate limit exceeded', {
'userId': userId,
'reason': 'too_many_otp_sends',
'timestamp': DateTime.now(),
});
Testing Strategy#
Unit Tests#
test('OTP generation produces 6-digit code', () {
final otp = OtpService.generateOtp();
expect(otp.length, equals(6));
expect(int.tryParse(otp), isNotNull);
});
test('generateOtpWithHash produces valid result', () {
const email = 'test@example.com';
final result = OtpService.generateOtpWithHash(email: email);
expect(result.otp, hasLength(6));
expect(result.hash, hasLength(64)); // SHA-256 hash
expect(result.salt, isNotEmpty);
expect(result.createdAt, isNotNull);
});
test('OTP verification succeeds with correct code', () {
const otp = '123456';
final salt = base64Encode(utf8.encode('1705324245000000:test@example.com'));
final hash = OtpService.hashOtp(otp: otp, salt: salt);
final isValid = OtpService.verifyOtp(
otp: otp,
storedHash: hash,
storedSalt: salt,
);
expect(isValid, isTrue);
});
test('OTP verification fails with incorrect code', () {
const correctOtp = '123456';
const incorrectOtp = '654321';
final salt = base64Encode(utf8.encode('1705324245000000:test@example.com'));
final hash = OtpService.hashOtp(otp: correctOtp, salt: salt);
final isValid = OtpService.verifyOtp(
otp: incorrectOtp,
storedHash: hash,
storedSalt: salt,
);
expect(isValid, isFalse);
});
test('Salt is base64 encoded', () {
final email = 'test@example.com';
final timestamp = DateTime.now();
final salt = OtpService.generateSalt(email: email, timestamp: timestamp);
// Should be valid base64
expect(() => base64Decode(salt), returnsNormally);
// Decode and verify content
final decoded = utf8.decode(base64Decode(salt));
expect(decoded, contains(':'));
expect(decoded, contains(email));
});
test('Rate limiting prevents rapid resends', () async {
final limiter = EmailVerificationLimiter();
final userId = 'test-user';
expect(await limiter.canSendOtp(userId), isTrue);
await limiter.recordOtpSend(userId);
expect(await limiter.canSendOtp(userId), isFalse);
});
Integration Tests#
test('Complete email verification flow', () async {
// 1. User logs in
final loginResponse = await authService.login(email, password);
expect(loginResponse.isEmailVerified, isFalse);
// 2. OTP sent automatically
// (verified via email mock)
// 3. User verifies OTP
final verifyResult = await emailVerificationService.verifyOtp(userId, otp);
expect(verifyResult, isTrue);
// 4. Status updated
final status = await emailVerificationService.getStatus(userId);
expect(status.isVerified, isTrue);
});
Configuration#
Environment Variables#
# Email Service
EMAIL_PROVIDER=sendgrid
EMAIL_API_KEY=your-api-key-here
EMAIL_FROM_ADDRESS=noreply@containerpub.com
# OTP Configuration
OTP_EXPIRY_MINUTES=5
OTP_MAX_ATTEMPTS=5
OTP_RESEND_COOLDOWN_SECONDS=60
# Rate Limiting
MAX_OTP_SENDS_PER_HOUR=5
MAX_OTP_SENDS_PER_DAY=10
VERIFICATION_LOCKOUT_MINUTES=15
Best Practices#
For Developers#
- Always validate OTP format - Check 6 digits before API call
- Implement proper error handling - Show user-friendly messages
- Use HTTPS - Secure all communication
- Respect rate limits - Don't retry immediately
- Log verification events - For debugging and auditing
For Operations#
- Monitor email delivery - Track send success rates
- Set up alerts - For high failure rates
- Regular backups - Include verification data
- Audit logs - Keep verification history
- Test email service - Regular health checks
Next Steps#
- Frontend Integration - Implement UI
- API Reference - Complete endpoint documentation
- Authentication System - Auth flow details