Skip to content

Verify email

Require users to verify an email before accessing protected routes.

This guide adds the verify-email feature.

The verify-email feature lets users verify an email before accessing protected routes. It uses verification features such as otp or magic-link as verification strategies. These features can be used not only for authentication, but also by other flows that need to verify account ownership.

  • A project completed from Add magic link
  • SMTP credentials for sending emails

This guide stores the verified email in a nullable verifiedEmail field.

Update prisma/schema.prisma:

prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
verifiedEmail String?
}

Create and apply a migration:

Terminal window
pnpm dlx prisma migrate dev --name add-verified-email

Then generate Prisma Client:

Terminal window
pnpm dlx prisma generate

Add the feature

Run the CLI command:

Terminal window
brkpt auth add verify-email

The CLI adds a new features/verify-email/ folder.

Validate the DTOs

This guide already uses ValidationPipe, so add validation decorators to the generated DTOs:

src/brkpt-auth/features/verify-email/dto/send.dto.ts
import { IsString } from 'class-validator';
export class SendDto {
@IsString()
target!: string;
@IsString()
strategy!: string;
}
src/brkpt-auth/features/verify-email/dto/verify.dto.ts
import { IsOptional, IsString } from 'class-validator';
export class VerifyDto {
@IsOptional()
@IsString()
target?: string;
@IsString()
strategy!: string;
@IsString()
proof!: string;
}

The verify-email feature always verifies an email, so the send request does not need a method field.

Implement the adapter

Create an adapter that reads and updates the user’s verified email:

  • Directorysrc/
    • Directorybrkpt-auth/
      • Directoryadapters/
        • verify-email.adapter.ts
src/brkpt-auth/adapters/verify-email.adapter.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { VerifyEmailPort } from '../features/verify-email/verify-email.port';
import { AuthJwtPayload } from './types';
@Injectable()
export class VerifyEmailAdapter implements VerifyEmailPort {
constructor(private readonly prisma: PrismaService) {}
async isVerified(payload: AuthJwtPayload): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
return user?.verifiedEmail != null;
}
async markVerified(
payload: AuthJwtPayload,
target: string,
): Promise<void> {
await this.prisma.user.update({
where: { id: payload.sub },
data: {
verifiedEmail: target,
},
});
}
extractUserIdFromJwtPayload(payload: AuthJwtPayload): number {
return payload.sub;
}
}

Register the feature

Update features.ts and pass VerifyEmailAdapter to verifyEmailFeature:

src/brkpt-auth/features.ts
import { BlacklistAdapter } from './adapters/blacklist.adapter';
import { ChangePasswordAdapter } from './adapters/change-password.adapter';
import { CoreAdapter } from './adapters/core.adapter';
import { CredentialsAdapter } from './adapters/credentials.adapter';
import { MagicLinkAdapter } from './adapters/magic-link.adapter';
import { OAuthAdapter } from './adapters/oauth.adapter';
import { OtpAdapter } from './adapters/otp.adapter';
import { ResetPasswordAdapter } from './adapters/reset-password.adapter';
import { SessionAdapter } from './adapters/session.adapter';
import { VerifyEmailAdapter } from './adapters/verify-email.adapter';
import { FeatureConfig } from './common/interfaces';
import { blacklistFeature } from './features/blacklist/blacklist.feature';
import { changePasswordFeature } from './features/change-password/change-password.feature';
import { coreFeature } from './features/core/core.feature';
import { credentialsFeature } from './features/credentials/credentials.feature';
import { EmailMagicLinkDriver } from './features/magic-link/drivers/email.driver';
import { magicLinkFeature } from './features/magic-link/magic-link.feature';
import { GithubOAuthDriver } from './features/oauth/drivers/github.driver';
import { GoogleOAuthDriver } from './features/oauth/drivers/google.driver';
import { oauthFeature } from './features/oauth/oauth.feature';
import { EmailOtpDriver } from './features/otp/drivers/email.driver';
import { otpFeature } from './features/otp/otp.feature';
import { resetPasswordFeature } from './features/reset-password/reset-password.feature';
import { sessionFeature } from './features/session/session.feature';
import { verifyEmailFeature } from './features/verify-email/verify-email.feature';
export const features: FeatureConfig[] = [
coreFeature(CoreAdapter),
blacklistFeature(BlacklistAdapter),
verifyEmailFeature(VerifyEmailAdapter),
credentialsFeature(CredentialsAdapter),
sessionFeature(SessionAdapter),
oauthFeature(OAuthAdapter, GoogleOAuthDriver, GithubOAuthDriver),
otpFeature(OtpAdapter, EmailOtpDriver),
magicLinkFeature(MagicLinkAdapter, EmailMagicLinkDriver),
changePasswordFeature(ChangePasswordAdapter),
resetPasswordFeature(ResetPasswordAdapter),
];

Registering verify-email also enables its global guard. Public routes remain accessible, while other routes require a verified email unless they are explicitly exempted.

Use @SkipVerifyEmail() on routes that should remain accessible before the user verifies an email.

For example, you may allow users to view their account and sign out:

src/brkpt-auth/features/core/core.controller.ts
import { SkipVerifyEmail } from '../../common/decorators/skip-verify-email.decorator';
@SkipVerifyEmail()
@Get('me')
me(@Req() req: BrkptAuthRequest) {
return this.coreService.me(req.user!);
}
@SkipVerifyEmail()
@Post('sign-out')
@HttpCode(200)
async signOut(
@Req() req: BrkptAuthRequest,
@Res({ passthrough: true }) response: Response,
) {
await this.coreService.signOut(req.user!, extractRequestMetadata(req));
clearRefreshToken(response);
return 'Signed out successfully';
}

Which routes should be exempt depends on your application. For the verification test below, leave @SkipVerifyEmail() off me() so /auth/me can demonstrate the difference before and after verification.

OTP can be used immediately with the configuration from the previous guides.

To also use magic link for email verification, add the verifyEmail callback URL:

src/app.module.ts
magicLink: {
expiresIn: '5m',
callbackUrls: {
authenticate: 'http://localhost:3000/auth/magic-link/authenticate',
resetPassword: 'http://localhost:3000/auth/reset-password/reset',
verifyEmail: 'http://localhost:3000/auth/verify-email/verify',
},
},

Start the application:

Terminal window
pnpm start:dev

The verify-email feature adds two endpoints:

Method Path Description
POST /auth/verify-email/send Send email verification
POST /auth/verify-email/verify Verify the email

Before verification, a protected route such as /auth/me returns 403 Forbidden:

GET /auth/me
Authorization: Bearer <access-token>
{
"message": "Your account does not have a verified email. Please verify an email to continue.",
"error": "Forbidden",
"statusCode": 403
}

Verify an email with OTP

Send an OTP to the email you want to verify:

POST /auth/verify-email/send
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "kevin@example.com",
"strategy": "otp"
}

The email contains an OTP for the verification flow:

Subject: Your OTP code to verify your email
Your OTP code is: 127419

Submit the target and verification proof:

POST /auth/verify-email/verify
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "kevin@example.com",
"strategy": "otp",
"proof": "127419"
}

After verification, /auth/me becomes accessible:

GET /auth/me
Authorization: Bearer <access-token>
{
"id": 1,
"name": "Kevin",
"email": "kevin@example.com",
"verifiedEmail": "kevin@example.com"
}

Verify an email with magic link

Send a magic link to the email you want to verify:

POST /auth/verify-email/send
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "kevin@example.com",
"strategy": "magic-link"
}

The email contains a link like:

Subject: Your magic link to verify your email
Click the link to continue: http://localhost:3000/auth/verify-email/verify?token=<magic-link-token>

For this backend-only example, copy the token from the link and submit it as the verification proof:

POST /auth/verify-email/verify
Authorization: Bearer <access-token>
Content-Type: application/json
{
"strategy": "magic-link",
"proof": "<magic-link-token>"
}

Magic link verification does not require target because the verified email is recovered from the token.

A successful verification stores the verified email through your adapter and allows the user to access routes protected by the global verify-email guard.