LogoContainerPub

Email Verification System

Complete email OTP verification implementation for ContainerPub

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#

  1. EmailVerificationService - Core business logic
  2. EmailVerificationMiddleware - Rate limiting and request validation
  3. EmailVerificationLimiter - Cooldown and attempt tracking
  4. Database Layer - User verification status storage
  5. Email Provider - External email delivery service

Data Flow#

User Registration/LoginCheck Email Verification StatusIf Not Verified:
    ├→ Generate OTP
    ├→ Store in Database
    ├→ Send via Email
    └→ Return StatusUser Submits OTPValidate OTP
    ├→ Check Expiry
    ├→ Check Attempts
    └→ Verify CodeMark Email as VerifiedReturn 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 token
  • 500 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 expired
  • 401 Unauthorized - Invalid token
  • 429 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 active
  • 401 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

ColumnTypeDescription
idSERIALPrimary key
user_uuidVARCHARForeign key to users (UUID)
otp_hashVARCHARHMAC-SHA256 hash of OTP
saltTEXTBase64 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

ColumnTypeDescription
user_idUUIDPrimary key (foreign key)
emailVARCHARUser's email address
is_verifiedBOOLEANVerification status
verified_atTIMESTAMPVerification completion time
last_otp_sent_atTIMESTAMPLast 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:

  1. OTP was generated with timestamp T1
  2. Database stored created_at with timestamp T2 (from CURRENT_TIMESTAMP)
  3. Verification failed because salt was based on T1 but retrieved T2

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#

ScenarioStatusResponse
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 verified400{"error": "Email already verified"}
User not found404{"error": "User not found"}

Error Response Format#

{
  "error": "error_code",
  "message": "Human-readable message",
  "details": {
    "attemptsRemaining": 2,
    "cooldownSeconds": 45
  }
}

Security Considerations#

OTP Security#

  1. 6-Digit Codes - 1 million possible combinations
  2. 24-Hour Expiry - Configurable validity period
  3. Attempt Limiting - 5 attempts per OTP
  4. HMAC-SHA256 Hashing - Strong cryptographic hash with salt
  5. Base64 Encoded Salt - Obfuscates timestamp and email
  6. Unique Salts - Each OTP has unique salt (timestamp:email)
  7. Secure Generation - Use Random.secure() with timestamp seed
  8. Timestamp Consistency - Prevents hash mismatch issues

Rate Limiting#

  1. Per-User Limits - Prevent individual abuse
  2. Cooldown Timers - Space out requests
  3. Exponential Backoff - Increase penalties
  4. IP-Based Limits - Optional additional layer

Email Delivery#

  1. HTTPS Only - Secure transmission
  2. Signed Emails - Optional DKIM/SPF
  3. No Sensitive Data - Don't include passwords
  4. 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#

  1. Always validate OTP format - Check 6 digits before API call
  2. Implement proper error handling - Show user-friendly messages
  3. Use HTTPS - Secure all communication
  4. Respect rate limits - Don't retry immediately
  5. Log verification events - For debugging and auditing

For Operations#

  1. Monitor email delivery - Track send success rates
  2. Set up alerts - For high failure rates
  3. Regular backups - Include verification data
  4. Audit logs - Keep verification history
  5. Test email service - Regular health checks

Next Steps#