# Build Your Own Email Verification System with Cloudflare Workers
- **URL:** https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers
- **Markdown URL:** https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers.md
- **JSON URL:** https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers.json
- **Author:** Harshcodez
- **Published Date:** 2026-08-10
- **Reading Time:** 12 min read
- **Tags:** api security, backend development, bun, cloudflare email routing, cloudflare workers, email verification, serverless, typescript

> "What if the verification e-mail was not sent by you but the user?" This post will describe how to create a serverless process using Cloudflare Email Routing and Cloudflare Workers as SendGrid alternatives, and then validate the hashed token using the mailto: response from the user. No need for SPF/DKIM/Sender Reputation management here!

# Build Your Own Email Verification System with Cloudflare Workers

How many times have you ended up paying for a service like **SendGrid** just to handle email verification?

And then you have to worry about things like email deliverability, sender reputation, SPF, DKIM, bounce rates, and whether your verification emails are ending up in spam.

For a simple email verification system, that can feel like a lot of infrastructure for something that should be pretty straightforward.

So what if we just didn't send the verification email ourselves?

Instead, we'll let the **user send us an email**.

All we need is a **Cloudflare account and a domain**. Cloudflare receives the email, triggers a Worker, and our Worker passes the verification information to our backend.

Because we're receiving the verification email rather than sending it from our own mail server, we don't have to build and maintain an outbound email-sending system or worry about our verification emails building up a sender reputation in the same way a service like SendGrid would.

And the best part is that if you already use Cloudflare and have a domain, the additional infrastructure is tiny.

Let's build it.

## How It Works

The flow is pretty straightforward:

```mermaid id="7lq4m2"
flowchart TD
    A([User Registers]) --> B[Backend Generates Verification Token]
    B --> C[Hash Token & Store in Database]
    C --> D[Create mailto Link]
    D --> E[User Sends Verification Email]

    E --> F[Cloudflare Email Routing]
    F --> G[Cloudflare Worker Receives Email]

    G --> H[Validate Email & Timestamp]
    H --> I[Send Verification Data to Backend]

    I --> J[Backend Validates Worker API Key]
    J --> K[Find Verification Record]
    K --> L[Check Token Expiry]
    L --> M[Verify Token Against Bcrypt Hash]

    M --> N{Token Valid?}

    N -- Yes --> O([Mark Email as Verified])
    N -- No --> P([Reject Verification])
```

The user registers, the backend creates a verification token, and we create a `mailto:` link containing that token.

The user clicks the link, their mail client opens, and they send the email.

Cloudflare receives it and triggers our Worker.

The Worker checks the email and sends the information to our backend.

The backend checks the token and marks the email as verified.

That's the whole idea.

---

## Why Not Just Use SendGrid?

Services like SendGrid are great when you need to **send emails at scale**.

But for basic email verification, you might not actually need a complete transactional email system.

Normally the flow looks like:

```text
Your Backend
     ↓
Email Service
     ↓
SMTP / Email Infrastructure
     ↓
User's Inbox
```

Now you have to care about things like:

* Sender reputation
* Email deliverability
* SPF
* DKIM
* DMARC
* Bounce handling
* Suppression lists
* Spam complaints
* Sending limits

With our approach, we're doing the opposite:

```text
User's Email Client
        ↓
Cloudflare
        ↓
Cloudflare Worker
        ↓
Your Backend
```

We're not sending the verification email from our infrastructure.

The user is sending the email from **their own email provider**.

That means we don't have to maintain an outbound mail-sending system just for verification emails.

### But There's a Catch

This doesn't magically solve every email-related problem.

We're still accepting emails from the public internet, so we need to protect the receiving side from abuse.

That's why the Worker validates the incoming email, the backend validates the token, and a production implementation should also have **rate limiting, token expiration, cleanup, and other spam protections**.

So we're avoiding the outbound email-deliverability problem — we're not avoiding security.

---

## Email Routing Setup

First, we need an email address that Cloudflare can receive emails on.

Go to [**Cloudflare Email Routing**](https://dash.cloudflare.com/?to=%2F%3Aaccount%2Femail-service%2Frouting&utm_source=harshcodez.com).

1. Click **+ Onboard Domain** in the top-right corner.
2. Under **Zone**, select the domain you want to use.
3. Click **Activate**.
4. Cloudflare will automatically configure the required DNS records.

For this example, we'll use:

```text
verification@hhczz.xyz
```

You can use your own domain and email address here.

Once the domain setup is complete, we're ready to move on.

---

## Building the Cloudflare Worker

### Initialization

Now we will initialize the Cloudflare Worker template using:

```bash
npm create cloudflare@latest
```

It will ask you a few questions:

1. **Directory:** Leave it blank or choose whatever directory you want.
2. **Template:** Choose **Hello World**.
3. **Type:** Choose **Worker only**.
4. **Language:** Choose **TypeScript**.

Once the project is created, open it in your editor.

### Worker Environment Variables

Our Worker needs to communicate with our backend, so we'll need:

```text
BACKEND_URL
INTERNAL_API_KEY
```

`BACKEND_URL` is the URL of our backend.

`INTERNAL_API_KEY` is a secret shared between the Worker and our backend. The backend uses it to make sure that the request came from our Worker.

For production, store the key as a Cloudflare secret rather than putting it directly in your source code.

---

## Writing the Worker

The Worker itself is actually pretty small:

```ts
export default {
	async email(message, env, ctx): Promise<void> {
		const from = message.from;
		const subject = message.headers.get('subject');
		const timestamp = message.headers.get('date');

		const backendUrl = env.BACKEND_URL;
		const apikey = env.INTERNAL_API_KEY;

		if (!from || !subject || !timestamp) {
			return;
		}

		const emailTime = Date.parse(timestamp);
		const age = Date.now() - emailTime;

		if (
			Number.isNaN(emailTime) ||
			age < 0 ||
			age > 10 * 60 * 1000
		) {
			return;
		}

		await fetch(`${backendUrl}/api/internal/email-worker`, {
			method: 'POST',
			headers: {
				'Content-Type': 'application/json',
				'x-internal-key': apikey,
			},
			body: JSON.stringify({
				from,
				subject,
			}),
		});
	},
} satisfies ExportedHandler<Env>;
```

Whenever an email arrives, Cloudflare calls the `email()` function.

We get the sender, subject and date:

```ts
const from = message.from;
const subject = message.headers.get('subject');
const timestamp = message.headers.get('date');
```

If any of those are missing, we ignore the email.

Then we check the email timestamp and reject anything older than 10 minutes.

If everything looks fine, we send the sender and subject to our backend.

The Worker doesn't need to know anything about our users or database.

It's basically just the bridge between **Cloudflare Email Routing and our backend**.

---

## Mock Backend

For the backend, I'm using **Bun + Express** as a simple mock implementation.

It has two endpoints:

```text
POST /api/email-verification
```

This creates the verification token and returns the `mailto:` link.

And:

```text
POST /api/internal/email-worker
```

This is called by our Cloudflare Worker when the email arrives.

> **Note:** The `userId` used in this mock backend is only there to keep the example simple. A real implementation should use a dedicated verification record/token and associate it with the user internally rather than relying on a user ID passed through the email.

Here's the backend:

```ts
import express, { type Request, type Response } from "express";
import {
	readDb,
	saveVerificationRecord,
	getVerificationByUserId,
	markVerificationAsVerified,
} from "./db";

const app = express();

app.use(express.json());

const PORT = process.env.PORT || 3000;

const verificationEmail = "verification@hhczz.xyz";
const internalAccessKey = "random-32-char-key";

app.post(
	"/api/email-verification",
	async (req: Request, res: Response) => {
		const userId = req.body.userId as string;
		const email = req.body.email as string;

		if (!email || !userId) {
			return res
				.status(400)
				.json({
					message: "Invalid Payload",
					error: "Bad request",
				});
		}

		const verificationToken = crypto.randomUUID();

		const hashedToken = await Bun.password.hash(
			verificationToken,
			{
				algorithm: "bcrypt",
			},
		);

		await saveVerificationRecord({
			userId,
			email,
			hashedToken,
		});

		const subject = `${userId},${verificationToken}`;

		const body = `
AUTOMATED VERIFICATION EMAIL

Please send to this email without making any changes.
Do not edit the subject or message body.
Changes to this email may cause verification to fail.
`;

		const mailto =
			`mailto:${verificationEmail}` +
			`?subject=${encodeURIComponent(subject)}` +
			`&body=${encodeURIComponent(body)}`;

		return res.send(mailto);
	},
);

app.post(
	"/api/internal/email-worker",
	async (req: Request, res: Response) => {
		if (req.headers["x-internal-key"] !== internalAccessKey) {
			return res
				.status(401)
				.json({
					message: "Unauthorized",
					error: "Invalid access key",
				});
		}

		const { from, subject } = req.body || {};

		if (!from || !subject) {
			return res
				.status(400)
				.json({
					message: "Invalid Payload",
					error: "Bad request",
				});
		}

		const [userId, token] = subject.split(",");

		if (!userId || !token) {
			return res
				.status(400)
				.json({
					message: "Invalid Subject",
					error: "Bad request",
				});
		}

		const verification =
			await getVerificationByUserId(userId);

		if (!verification) {
			return res
				.status(404)
				.json({
					message: "Verification not found",
					error: "Not found",
				});
		}

		if (from !== verification.email) {
			return res
				.status(400)
				.json({
					message: "Invalid email",
					error: "Bad request",
				});
		}

		if (verification.verified) {
			return res
				.status(400)
				.json({
					message: "Verification already done",
					error: "Bad request",
				});
		}

		const isTokenValid = await Bun.password.verify(
			token,
			verification.hashedToken,
		);

		if (!isTokenValid) {
			return res
				.status(401)
				.json({
					message: "Invalid token",
					error: "Unauthorized",
				});
		}

		await markVerificationAsVerified(userId);

		return res.json({
			message: "Email worker triggered",
		});
	},
);

app.get("/", async (req, res) => {
	res.json({
		message: "alive",
	});
});

app.listen(PORT, () => {
	console.log(
		`🚀 Server running with Bun & Express on http://localhost:${PORT}`,
	);
});
```

---

## Generating the Verification Token

When a user registers, our frontend can call:

```text
POST /api/email-verification
```

with:

```json
{
	"userId": "testuser111",
	"email": "user@example.com"
}
```

The backend generates a random token:

```ts
const verificationToken = crypto.randomUUID();
```

We don't store the token itself.

Instead, we hash it using bcrypt:

```ts
const hashedToken = await Bun.password.hash(
	verificationToken,
	{
		algorithm: "bcrypt",
	},
);
```

Then we store the hash:

```ts
await saveVerificationRecord({
	userId,
	email,
	hashedToken,
});
```

---

## Creating the `mailto:` Link

Now we create the email that the user will send.

The token goes into the subject:

```ts
const subject = `${userId},${verificationToken}`;
```

Then we create the body:

```ts
const body = `
AUTOMATED VERIFICATION EMAIL

Please send to this email without making any changes.
Do not edit the subject or message body.
Changes to this email may cause verification to fail.
`;
```

And finally:

```ts
const mailto =
	`mailto:${verificationEmail}` +
	`?subject=${encodeURIComponent(subject)}` +
	`&body=${encodeURIComponent(body)}`;
```

The backend returns that URL to the frontend.

The frontend can use it directly:

```html
<a href="THE_MAILTO_URL">
	Verify Email
</a>
```

The user's mail client opens with everything already filled in.

They press **Send**.

That's it.

---

## Testing the Backend

Before connecting everything to the frontend, let's test the endpoint directly.

You can use:

```bash
curl -X POST "https://your-backend-url/api/email-verification" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "testuser111",
    "email": "user@example.com"
  }'
```

While testing locally, I used a temporary Cloudflare Tunnel to expose my Bun server:

```bash
curl -X POST "https://tablet-gregory-motivation-kingston.trycloudflare.com/api/email-verification" \
  -H "Content-Type: application/json" \
  -d '{
    "userId": "testuser111",
    "email": "harsh@harshcodez.com"
  }'
```

The backend returns a `mailto:` URL similar to:

```text
mailto:verification@hhczz.xyz?subject=testuser111%2C57527565-018c-44a3-b2cd-f17c38215655&body=%0AAUTOMATED%20VERIFICATION%20EMAIL%0A%0APlease%20send%20to%20this%20email%20without%20making%20any%20changes.%0ADo%20not%20edit%20the%20subject%20or%20message%20body.%0A%0AChanges%20to%20this%20email%20may%20cause%20verification%20to%20fail.
```

Decoded, that's basically:

```text
To:
verification@hhczz.xyz

Subject:
testuser111,57527565-018c-44a3-b2cd-f17c38215655

Body:
AUTOMATED VERIFICATION EMAIL

Please send to this email without making any changes.
Do not edit the subject or message body.
Changes to this email may cause verification to fail.
```

You can open the returned `mailto:` URL in your email client and send it.

The email should then travel through:

```text
Email Client
    ↓
Cloudflare Email Routing
    ↓
Cloudflare Worker
    ↓
Backend
```

---

## Backend Verification

When the Worker receives the email, it sends the sender and subject to:

```text
POST /api/internal/email-worker
```

The backend first checks the internal API key.

Then it gets the `userId` and token from the subject:

```ts
const [userId, token] = subject.split(",");
```

It finds the verification record, checks that the sender matches the email that originally requested verification, and finally verifies the token against the stored bcrypt hash:

```ts
const isTokenValid = await Bun.password.verify(
	token,
	verification.hashedToken,
);
```

If everything matches:

```ts
await markVerificationAsVerified(userId);
```

The email is verified.

---

## Things You Should Add Before Production

The mock implementation is intentionally simple. There are a few things I'd add before using this for a real application.

### Token Expiry

The Worker checks that the email is less than 10 minutes old, but the verification token itself should also have an expiration time.

Store something like:

```text
hashedToken
expiresAt
verified
```

Then the backend can reject expired tokens independently of the email timestamp.

### Delete Expired Records

Don't keep expired verification records forever.

Run a cleanup job that deletes records where:

```text
expiresAt < current time
```

You can also delete the record after successful verification if you don't need it for auditing.

### Single-Use Tokens

A token should only be usable once.

The mock implementation checks whether the record has already been verified, but in production you can also delete the verification record after successful verification.

### Spam Protection

The verification endpoint is public, so someone could repeatedly request new verification tokens.

Add rate limiting based on things like:

* User/account
* Email address
* IP address

You can also rate-limit verification attempts themselves.

This is especially important because, unlike outbound email, **your receiving address is publicly accessible**.

### Protect the Worker → Backend Endpoint

The Worker sends an internal API key:

```http
x-internal-key: ...
```

Keep that key secret and store it using your platform's secret management.

Don't hardcode:

```ts
const internalAccessKey = "random-32-char-key";
```

in production.

---

## What About Spam and Domain Reputation?

This is one of the nice parts of this approach.

With something like SendGrid, you're **sending** emails to users. That means deliverability becomes part of your problem.

Your emails need to reach the inbox, and your sending domain/IP reputation matters.

Here, we're doing the opposite.

The user sends the email to us.

```text
User's Email Provider
        ↓
verification@yourdomain.com
        ↓
Cloudflare
        ↓
Worker
        ↓
Backend
```

So we're not building an outbound mail server just to send a verification message.

There's no verification email from our server sitting in the user's spam folder because **our server isn't sending that email in the first place**.

The user's own mail provider is sending the message.

Of course, this doesn't mean you can completely ignore email abuse. Someone can still spam your receiving address, which is why the Worker and backend need validation and rate limiting.

But you don't have to spend your time worrying about the usual outbound verification-email setup and sender reputation just to get a simple verification flow working.

---

## Deploying the Worker

Once everything works locally, deploy the Worker:

```bash
npm run deploy
```

Make sure you configure:

```text
BACKEND_URL
INTERNAL_API_KEY
```

in your Worker environment.

Then configure your Cloudflare Email Routing rule to send the verification address to your Worker.

---

## The Final Flow

Once everything is connected, the whole system looks like this:

```mermaid
flowchart TD
    A([User Registers]) --> B[Backend Generates Verification Token]
    B --> C[Hash Token & Store in Database]
    C --> D[Create mailto Link]
    D --> E[User Sends Verification Email]

    E --> F[Cloudflare Email Routing]
    F --> G[Cloudflare Worker Receives Email]

    G --> H[Validate Email & Timestamp]
    H --> I[Send Verification Data to Backend]

    I --> J[Backend Validates Worker API Key]
    J --> K[Find Verification Record]
    K --> L[Check Token Expiry]
    L --> M[Verify Token Against Bcrypt Hash]

    M --> N{Token Valid?}

    N -- Yes --> O([Mark Email as Verified])
    N -- No --> P([Reject Verification])
```

So instead of:

```text
Your Backend
    ↓
SendGrid
    ↓
Email Infrastructure
    ↓
User
```

we have:

```text
Your Backend
    ↓
mailto:
    ↓
User
    ↓
Cloudflare
    ↓
Worker
    ↓
Your Backend
```

And that's the whole trick.

You don't need a full transactional email platform just to verify that a user controls an email address.

If you already have a domain and Cloudflare, this can be a surprisingly cheap and simple alternative for basic verification flows.

Just make sure you add the production protections before using it for a real application.

## Source Code

The complete Cloudflare Worker is available here:

[**harshcodezzz/cloudflare-email-verification-worker**](https://github.com/harshcodezzz/cloudflare-email-verification-worker)

Use it as a starting point and replace the mock backend with your own authentication system.
