Description
Require email verification for all new user registrations. Send verification emails with 24-hour expiry links, support resend functionality, restrict unverified users from accessing certain features, and display a verified badge. Include comprehensive tests for email delivery, token expiry, and verification state transitions.
- Reduce spam and fake account registrations.
- Ensure users can access their registered email accounts.
- Create a foundational security checkpoint for account recovery and notifications.
- Improve user trust with a verification badge.
Scope
- Add email verification state tracking to
User model.
- Send transactional email on registration containing a verification link.
- Generate secure, time-bound verification tokens (24-hour expiry).
- Create resend verification email endpoint.
- Restrict unverified users from sensitive features (donations, distributions, KYC).
- Display verified status badge on user profiles and campaign pages.
- Add comprehensive tests for email delivery, token expiry, and state transitions.
Database changes (Prisma schema)
Update schema.prisma User model:
model User {
// ... existing fields
emailVerified Boolean @default(false)
verificationToken String? @unique
verificationExpiry DateTime?
// ... rest of model
}
Add new model for audit trail (optional but recommended):
model VerificationLog {
id String @id @default(cuid())
userId String
action String // 'SENT' | 'VERIFIED' | 'EXPIRED' | 'RESENT'
tokenHash String? // hash of token, not plaintext
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([createdAt])
}
Rationale: Track verification attempts for security audit and abuse detection.
API contracts (endpoints)
Registration endpoint (existing, modified behavior)
POST /api/v1/auth/register
- Behavior change: on successful user creation, immediately:
- Generate verification token (secure random string, 32+ bytes, base64-encoded)
- Set
verificationExpiry = now() + 24 hours
- Send verification email with link:
{baseUrl}/verify-email?token={token}
- Create
VerificationLog entry with action SENT
- Response:
{ success: true, message: 'User created. Check your email to verify.', userId }
- Do not auto-login; require email verification before first login (see restrictions below)
Verify email endpoint (new)
GET /api/v1/auth/verify-email?token=<token>
- Query param:
token (verification token from email link)
- No authentication required
- Behavior:
- Look up user by
verificationToken
- Check
verificationExpiry > now() (reject if expired)
- On success:
- Set
emailVerified = true
- Clear
verificationToken and verificationExpiry
- Create
VerificationLog with action VERIFIED
- Return:
{ success: true, message: 'Email verified. You can now log in.' }
- On failure (expired/invalid):
- Return:
{ success: false, message: 'Verification link expired or invalid.', code: 'VERIFICATION_FAILED' }
- Suggest resend via endpoint below
- Optionally auto-redirect to frontend URL after verification
Resend verification email endpoint (new)
POST /api/v1/auth/resend-verification
- Body:
{ email }
- No authentication required
- Behavior:
- Find user by email
- Check if already verified → return
{ already_verified: true }
- Generate new token and set new expiry (24h from now)
- Resend email
- Create
VerificationLog with action RESENT
- Rate-limit: max 3 resends per hour per email (use Redis)
- Return:
{ success: true, message: 'Verification email sent.' }
Verification status endpoint (existing profile endpoint, extended)
GET /api/v1/auth/me or GET /api/v1/users/profile
- Extended response to include:
{ emailVerified: boolean, verificationSentAt?: DateTime }
Unverified user restrictions
Implement role/permission checks in controllers to block unverified users from:
POST /api/v1/donations — only verified users can donate
POST /api/v1/beneficiaries/:id/kyc — only verified beneficiaries can submit KYC
POST /api/v1/distributions — only verified organization staff can create distributions
- Admin/verifier actions (recommend verified-only for sensitive roles)
In auth.ts or per-controller, add check:
if (req.user && !req.user.emailVerified && isRestrictedEndpoint(req)) {
throw new AppError('Please verify your email before accessing this feature.', 403);
}
Provide clear error message: { success: false, code: 'EMAIL_NOT_VERIFIED', message: 'Please verify your email. Resend link?', resendUrl: '/api/v1/auth/resend-verification' }
Return 403 Forbidden with the resend link in response body.
Email template
Send transactional email on registration and resend:
Subject: Verify your AidLink account
Body (HTML + plain text):
Hi {firstName},
Welcome to AidLink! To get started, please verify your email address by clicking the link below:
{verificationLink}
This link will expire in 24 hours.
If you didn't create this account, please ignore this email.
Questions? Support: support@aidlink.org
Plain text fallback: same but without HTML tags.
Verification link format: https://app.aidlink.org/verify-email?token={token}
- Token embedded in URL (querystring)
- Backend validates token format and expiry on
GET /api/v1/auth/verify-email?token=...
Verified badge
User profile response
Add field to user responses:
{
"id": "...",
"email": "...",
"emailVerified": true,
"verifiedAt": "2026-06-18T12:00:00Z",
...
}
Campaign/beneficiary profile response
When showing a campaign owner or beneficiary, include:
{
"organizationName": "...",
"owner": {
"id": "...",
"name": "...",
"emailVerified": true
},
...
}
Frontend badge
Display a checkmark or "Verified" badge next to verified user names in:
- Campaign owner info
- Beneficiary profile
- Donor profiles (if public)
- Admin/verifier credentials
Token generation & security
- Generate token:
crypto.randomBytes(32).toString('base64url')
- Store in DB: use hash of token (SHA256) to avoid exposing plaintext in DB
- On verification, hash incoming token and compare with stored hash
- Example in code:
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
await prisma.user.update({
where: { id },
data: {
verificationToken: tokenHash,
verificationExpiry: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
// Send email with plaintext token
Business rules
- Email case-insensitivity: store emails lowercase; compare case-insensitively
- Token reuse prevention: each resend generates a new token; old tokens become invalid
- Rate limiting:
- Resend: max 3 per hour per email
- Verification attempts: track failed attempts; lock after 10 failed attempts (require resend)
- Expiry handling:
- Expired tokens are invalid; user must request resend
- Show friendly message and resend link in error response
- Already-verified users: attempting to verify again (e.g., old link) should return success or a friendly "already verified" message
- Login before verification: unverified users should not be able to obtain JWT tokens on login (or issue limited-scope tokens that only allow verification endpoints)
Notification flow
- User submits registration form
- API validates and creates
User with emailVerified = false
- Immediately send verification email (async job or synchronous call)
- Return success response (do NOT auto-login)
- User checks email and clicks link
- API verifies token, sets
emailVerified = true, logs event
- Redirect user to login or auto-login screen
- On login, check
emailVerified; if false, reject and prompt resend
Testing plan
Unit tests (src/services/auth.service.test.ts)
- Generate verification token: valid format, 32+ bytes
- Verify email with valid token:
- Token matches, not expired → user marked verified
- Token invalid → returns error
- Token expired → returns error with resend suggestion
- Resend verification:
- User not yet verified → new token issued, email sent
- User already verified → returns already_verified or success gracefully
- Rate limit enforced (max 3 resends/hour)
- Token expiry: token generation sets correct 24-hour expiry
Integration tests (src/routes/auth.routes.test.ts)
- Full flow:
POST /api/v1/auth/register → user created, email queued
- Mock email delivery; verify email body contains token
GET /api/v1/auth/verify-email?token=<token> → user verified
POST /api/v1/auth/login → now allowed (user verified)
- Expired token:
- Create user with token expiry set to past
GET /api/v1/auth/verify-email?token=<old> → fails
POST /api/v1/auth/resend-verification → new token issued
- Unverified restrictions:
- Create unverified user
- Attempt
POST /api/v1/donations → rejected with 403 and resend link
- Verify email
- Retry
POST /api/v1/donations → allowed
Email delivery tests
- Mock email service (e.g., Nodemailer, SendGrid SDK)
- Verify email is sent on:
- User registration
- Resend verification
- Verify email contains correct token and link format
- Verify subject and body text
Token expiry tests
- Set token expiry to 1 second in future
- Wait 2 seconds
- Attempt verification → fails with expiry error
Concurrency tests
- Simulate two resend requests in quick succession (within 1 second)
- Verify only one email is sent and rate limit is enforced
Security tests
- Token is not returned in any API response (only sent via email)
- Token hash is stored in DB, not plaintext
- Attempt to guess/brute-force verification tokens fails after N attempts
- Verification tokens are unique per user and per request (cannot reuse old token after new one issued)
Implementation checklist
Files likely to change
- schema.prisma (add
emailVerified, verificationToken, verificationExpiry, optional VerificationLog)
- auth.service.ts (register, verify, resend methods)
- auth.controller.ts (verify, resend endpoints)
- auth.routes.ts (add
/verify-email, /resend-verification routes)
- auth.ts (add email verification check for restricted endpoints)
src/utils/email.ts or similar (email sending utility)
src/templates/verify-email.html and .txt (new email templates)
tests/auth.service.test.ts (unit tests)
tests/auth.routes.test.ts (integration tests)
openapi.yaml (document new endpoints)
- README.md (verification flow documentation)
Environment variables & config
EMAIL_FROM — sender address (e.g., noreply@aidlink.org)
EMAIL_PROVIDER — provider (sendgrid, nodemailer, aws-ses, etc.)
VERIFICATION_TOKEN_EXPIRY_HOURS — default 24
VERIFICATION_RESEND_RATE_LIMIT — resends per hour (default 3)
VERIFICATION_MAX_FAILED_ATTEMPTS — max failed verifications before lockout (default 10)
APP_BASE_URL — URL for verification link in email (e.g., https://app.aidlink.org)
Acceptance criteria
- New users must verify email before accessing restricted features
- Verification link expires after 24 hours
- Resend endpoint works with rate limiting (max 3/hour)
- Token is secure (not exposed in responses, hashed in DB)
- Unverified users see clear, actionable error messages with resend links
- Verified badge displays on profiles
- All tests pass (unit, integration, email delivery, token expiry, security)
- Documentation updated
Description
Require email verification for all new user registrations. Send verification emails with 24-hour expiry links, support resend functionality, restrict unverified users from accessing certain features, and display a verified badge. Include comprehensive tests for email delivery, token expiry, and verification state transitions.
Scope
Usermodel.Database changes (Prisma schema)
Update schema.prisma
Usermodel:Add new model for audit trail (optional but recommended):
Rationale: Track verification attempts for security audit and abuse detection.
API contracts (endpoints)
Registration endpoint (existing, modified behavior)
POST /api/v1/auth/registerverificationExpiry = now() + 24 hours{baseUrl}/verify-email?token={token}VerificationLogentry with actionSENT{ success: true, message: 'User created. Check your email to verify.', userId }Verify email endpoint (new)
GET /api/v1/auth/verify-email?token=<token>token(verification token from email link)verificationTokenverificationExpiry > now()(reject if expired)emailVerified = trueverificationTokenandverificationExpiryVerificationLogwith actionVERIFIED{ success: true, message: 'Email verified. You can now log in.' }{ success: false, message: 'Verification link expired or invalid.', code: 'VERIFICATION_FAILED' }Resend verification email endpoint (new)
POST /api/v1/auth/resend-verification{ email }{ already_verified: true }VerificationLogwith actionRESENT{ success: true, message: 'Verification email sent.' }Verification status endpoint (existing profile endpoint, extended)
GET /api/v1/auth/meorGET /api/v1/users/profile{ emailVerified: boolean, verificationSentAt?: DateTime }Unverified user restrictions
Implement role/permission checks in controllers to block unverified users from:
POST /api/v1/donations— only verified users can donatePOST /api/v1/beneficiaries/:id/kyc— only verified beneficiaries can submit KYCPOST /api/v1/distributions— only verified organization staff can create distributionsIn auth.ts or per-controller, add check:
Provide clear error message:
{ success: false, code: 'EMAIL_NOT_VERIFIED', message: 'Please verify your email. Resend link?', resendUrl: '/api/v1/auth/resend-verification' }Return 403 Forbidden with the resend link in response body.
Email template
Send transactional email on registration and resend:
Subject:
Verify your AidLink accountBody (HTML + plain text):
Plain text fallback: same but without HTML tags.
Verification link format:
https://app.aidlink.org/verify-email?token={token}GET /api/v1/auth/verify-email?token=...Verified badge
User profile response
Add field to user responses:
{ "id": "...", "email": "...", "emailVerified": true, "verifiedAt": "2026-06-18T12:00:00Z", ... }Campaign/beneficiary profile response
When showing a campaign owner or beneficiary, include:
{ "organizationName": "...", "owner": { "id": "...", "name": "...", "emailVerified": true }, ... }Frontend badge
Display a checkmark or "Verified" badge next to verified user names in:
Token generation & security
crypto.randomBytes(32).toString('base64url')Business rules
Notification flow
UserwithemailVerified = falseemailVerified = true, logs eventemailVerified; if false, reject and prompt resendTesting plan
Unit tests (
src/services/auth.service.test.ts)Integration tests (
src/routes/auth.routes.test.ts)POST /api/v1/auth/register→ user created, email queuedGET /api/v1/auth/verify-email?token=<token>→ user verifiedPOST /api/v1/auth/login→ now allowed (user verified)GET /api/v1/auth/verify-email?token=<old>→ failsPOST /api/v1/auth/resend-verification→ new token issuedPOST /api/v1/donations→ rejected with 403 and resend linkPOST /api/v1/donations→ allowedEmail delivery tests
Token expiry tests
Concurrency tests
Security tests
Implementation checklist
emailVerified,verificationToken,verificationExpiryfields toUsermodel in Prisma schemaVerificationLogmodel for audit trailAuthService.register()to:verifyEmail(token)method inAuthServiceresendVerificationEmail(email)method with rate limitingsrc/templates/verify-email.htmland.txt)GET /api/v1/auth/verify-email?token=endpointPOST /api/v1/auth/resend-verificationendpointemailVerifiedcheck middleware or per-controller guard for restricted endpointsPOST /api/v1/auth/loginto reject unverified users (or issue limited token)emailVerifiedflagopenapi.yaml) with new endpointsFiles likely to change
emailVerified,verificationToken,verificationExpiry, optionalVerificationLog)/verify-email,/resend-verificationroutes)src/utils/email.tsor similar (email sending utility)src/templates/verify-email.htmland.txt(new email templates)tests/auth.service.test.ts(unit tests)tests/auth.routes.test.ts(integration tests)openapi.yaml(document new endpoints)Environment variables & config
EMAIL_FROM— sender address (e.g.,noreply@aidlink.org)EMAIL_PROVIDER— provider (sendgrid, nodemailer, aws-ses, etc.)VERIFICATION_TOKEN_EXPIRY_HOURS— default 24VERIFICATION_RESEND_RATE_LIMIT— resends per hour (default 3)VERIFICATION_MAX_FAILED_ATTEMPTS— max failed verifications before lockout (default 10)APP_BASE_URL— URL for verification link in email (e.g.,https://app.aidlink.org)Acceptance criteria