{
  "title": "Build Your Own Email Verification System with Cloudflare Workers",
  "slug": "build-your-own-email-verification-system-with-cloudflare-workers",
  "topic": "hots",
  "url": "https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers",
  "formats": {
    "html": "https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers",
    "markdown": "https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers.md",
    "json": "https://harshcodez.com/hots/build-your-own-email-verification-system-with-cloudflare-workers.json"
  },
  "excerpt": "\"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!",
  "content": "# Build Your Own Email Verification System with Cloudflare Workers\n\nHow many times have you ended up paying for a service like **SendGrid** just to handle email verification?\n\nAnd 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.\n\nFor a simple email verification system, that can feel like a lot of infrastructure for something that should be pretty straightforward.\n\nSo what if we just didn't send the verification email ourselves?\n\nInstead, we'll let the **user send us an email**.\n\nAll 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.\n\nBecause 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.\n\nAnd the best part is that if you already use Cloudflare and have a domain, the additional infrastructure is tiny.\n\nLet's build it.\n\n## How It Works\n\nThe flow is pretty straightforward:\n\n```mermaid id=\"7lq4m2\"\nflowchart TD\n    A([User Registers]) --> B[Backend Generates Verification Token]\n    B --> C[Hash Token & Store in Database]\n    C --> D[Create mailto Link]\n    D --> E[User Sends Verification Email]\n\n    E --> F[Cloudflare Email Routing]\n    F --> G[Cloudflare Worker Receives Email]\n\n    G --> H[Validate Email & Timestamp]\n    H --> I[Send Verification Data to Backend]\n\n    I --> J[Backend Validates Worker API Key]\n    J --> K[Find Verification Record]\n    K --> L[Check Token Expiry]\n    L --> M[Verify Token Against Bcrypt Hash]\n\n    M --> N{Token Valid?}\n\n    N -- Yes --> O([Mark Email as Verified])\n    N -- No --> P([Reject Verification])\n```\n\nThe user registers, the backend creates a verification token, and we create a `mailto:` link containing that token.\n\nThe user clicks the link, their mail client opens, and they send the email.\n\nCloudflare receives it and triggers our Worker.\n\nThe Worker checks the email and sends the information to our backend.\n\nThe backend checks the token and marks the email as verified.\n\nThat's the whole idea.\n\n---\n\n## Why Not Just Use SendGrid?\n\nServices like SendGrid are great when you need to **send emails at scale**.\n\nBut for basic email verification, you might not actually need a complete transactional email system.\n\nNormally the flow looks like:\n\n```text\nYour Backend\n     ↓\nEmail Service\n     ↓\nSMTP / Email Infrastructure\n     ↓\nUser's Inbox\n```\n\nNow you have to care about things like:\n\n* Sender reputation\n* Email deliverability\n* SPF\n* DKIM\n* DMARC\n* Bounce handling\n* Suppression lists\n* Spam complaints\n* Sending limits\n\nWith our approach, we're doing the opposite:\n\n```text\nUser's Email Client\n        ↓\nCloudflare\n        ↓\nCloudflare Worker\n        ↓\nYour Backend\n```\n\nWe're not sending the verification email from our infrastructure.\n\nThe user is sending the email from **their own email provider**.\n\nThat means we don't have to maintain an outbound mail-sending system just for verification emails.\n\n### But There's a Catch\n\nThis doesn't magically solve every email-related problem.\n\nWe're still accepting emails from the public internet, so we need to protect the receiving side from abuse.\n\nThat'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**.\n\nSo we're avoiding the outbound email-deliverability problem — we're not avoiding security.\n\n---\n\n## Email Routing Setup\n\nFirst, we need an email address that Cloudflare can receive emails on.\n\nGo to [**Cloudflare Email Routing**](https://dash.cloudflare.com/?to=%2F%3Aaccount%2Femail-service%2Frouting&utm_source=harshcodez.com).\n\n1. Click **+ Onboard Domain** in the top-right corner.\n2. Under **Zone**, select the domain you want to use.\n3. Click **Activate**.\n4. Cloudflare will automatically configure the required DNS records.\n\nFor this example, we'll use:\n\n```text\nverification@hhczz.xyz\n```\n\nYou can use your own domain and email address here.\n\nOnce the domain setup is complete, we're ready to move on.\n\n---\n\n## Building the Cloudflare Worker\n\n### Initialization\n\nNow we will initialize the Cloudflare Worker template using:\n\n```bash\nnpm create cloudflare@latest\n```\n\nIt will ask you a few questions:\n\n1. **Directory:** Leave it blank or choose whatever directory you want.\n2. **Template:** Choose **Hello World**.\n3. **Type:** Choose **Worker only**.\n4. **Language:** Choose **TypeScript**.\n\nOnce the project is created, open it in your editor.\n\n### Worker Environment Variables\n\nOur Worker needs to communicate with our backend, so we'll need:\n\n```text\nBACKEND_URL\nINTERNAL_API_KEY\n```\n\n`BACKEND_URL` is the URL of our backend.\n\n`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.\n\nFor production, store the key as a Cloudflare secret rather than putting it directly in your source code.\n\n---\n\n## Writing the Worker\n\nThe Worker itself is actually pretty small:\n\n```ts\nexport default {\n\tasync email(message, env, ctx): Promise<void> {\n\t\tconst from = message.from;\n\t\tconst subject = message.headers.get('subject');\n\t\tconst timestamp = message.headers.get('date');\n\n\t\tconst backendUrl = env.BACKEND_URL;\n\t\tconst apikey = env.INTERNAL_API_KEY;\n\n\t\tif (!from || !subject || !timestamp) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst emailTime = Date.parse(timestamp);\n\t\tconst age = Date.now() - emailTime;\n\n\t\tif (\n\t\t\tNumber.isNaN(emailTime) ||\n\t\t\tage < 0 ||\n\t\t\tage > 10 * 60 * 1000\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\tawait fetch(`${backendUrl}/api/internal/email-worker`, {\n\t\t\tmethod: 'POST',\n\t\t\theaders: {\n\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t'x-internal-key': apikey,\n\t\t\t},\n\t\t\tbody: JSON.stringify({\n\t\t\t\tfrom,\n\t\t\t\tsubject,\n\t\t\t}),\n\t\t});\n\t},\n} satisfies ExportedHandler<Env>;\n```\n\nWhenever an email arrives, Cloudflare calls the `email()` function.\n\nWe get the sender, subject and date:\n\n```ts\nconst from = message.from;\nconst subject = message.headers.get('subject');\nconst timestamp = message.headers.get('date');\n```\n\nIf any of those are missing, we ignore the email.\n\nThen we check the email timestamp and reject anything older than 10 minutes.\n\nIf everything looks fine, we send the sender and subject to our backend.\n\nThe Worker doesn't need to know anything about our users or database.\n\nIt's basically just the bridge between **Cloudflare Email Routing and our backend**.\n\n---\n\n## Mock Backend\n\nFor the backend, I'm using **Bun + Express** as a simple mock implementation.\n\nIt has two endpoints:\n\n```text\nPOST /api/email-verification\n```\n\nThis creates the verification token and returns the `mailto:` link.\n\nAnd:\n\n```text\nPOST /api/internal/email-worker\n```\n\nThis is called by our Cloudflare Worker when the email arrives.\n\n> **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.\n\nHere's the backend:\n\n```ts\nimport express, { type Request, type Response } from \"express\";\nimport {\n\treadDb,\n\tsaveVerificationRecord,\n\tgetVerificationByUserId,\n\tmarkVerificationAsVerified,\n} from \"./db\";\n\nconst app = express();\n\napp.use(express.json());\n\nconst PORT = process.env.PORT || 3000;\n\nconst verificationEmail = \"verification@hhczz.xyz\";\nconst internalAccessKey = \"random-32-char-key\";\n\napp.post(\n\t\"/api/email-verification\",\n\tasync (req: Request, res: Response) => {\n\t\tconst userId = req.body.userId as string;\n\t\tconst email = req.body.email as string;\n\n\t\tif (!email || !userId) {\n\t\t\treturn res\n\t\t\t\t.status(400)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Invalid Payload\",\n\t\t\t\t\terror: \"Bad request\",\n\t\t\t\t});\n\t\t}\n\n\t\tconst verificationToken = crypto.randomUUID();\n\n\t\tconst hashedToken = await Bun.password.hash(\n\t\t\tverificationToken,\n\t\t\t{\n\t\t\t\talgorithm: \"bcrypt\",\n\t\t\t},\n\t\t);\n\n\t\tawait saveVerificationRecord({\n\t\t\tuserId,\n\t\t\temail,\n\t\t\thashedToken,\n\t\t});\n\n\t\tconst subject = `${userId},${verificationToken}`;\n\n\t\tconst body = `\nAUTOMATED VERIFICATION EMAIL\n\nPlease send to this email without making any changes.\nDo not edit the subject or message body.\nChanges to this email may cause verification to fail.\n`;\n\n\t\tconst mailto =\n\t\t\t`mailto:${verificationEmail}` +\n\t\t\t`?subject=${encodeURIComponent(subject)}` +\n\t\t\t`&body=${encodeURIComponent(body)}`;\n\n\t\treturn res.send(mailto);\n\t},\n);\n\napp.post(\n\t\"/api/internal/email-worker\",\n\tasync (req: Request, res: Response) => {\n\t\tif (req.headers[\"x-internal-key\"] !== internalAccessKey) {\n\t\t\treturn res\n\t\t\t\t.status(401)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Unauthorized\",\n\t\t\t\t\terror: \"Invalid access key\",\n\t\t\t\t});\n\t\t}\n\n\t\tconst { from, subject } = req.body || {};\n\n\t\tif (!from || !subject) {\n\t\t\treturn res\n\t\t\t\t.status(400)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Invalid Payload\",\n\t\t\t\t\terror: \"Bad request\",\n\t\t\t\t});\n\t\t}\n\n\t\tconst [userId, token] = subject.split(\",\");\n\n\t\tif (!userId || !token) {\n\t\t\treturn res\n\t\t\t\t.status(400)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Invalid Subject\",\n\t\t\t\t\terror: \"Bad request\",\n\t\t\t\t});\n\t\t}\n\n\t\tconst verification =\n\t\t\tawait getVerificationByUserId(userId);\n\n\t\tif (!verification) {\n\t\t\treturn res\n\t\t\t\t.status(404)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Verification not found\",\n\t\t\t\t\terror: \"Not found\",\n\t\t\t\t});\n\t\t}\n\n\t\tif (from !== verification.email) {\n\t\t\treturn res\n\t\t\t\t.status(400)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Invalid email\",\n\t\t\t\t\terror: \"Bad request\",\n\t\t\t\t});\n\t\t}\n\n\t\tif (verification.verified) {\n\t\t\treturn res\n\t\t\t\t.status(400)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Verification already done\",\n\t\t\t\t\terror: \"Bad request\",\n\t\t\t\t});\n\t\t}\n\n\t\tconst isTokenValid = await Bun.password.verify(\n\t\t\ttoken,\n\t\t\tverification.hashedToken,\n\t\t);\n\n\t\tif (!isTokenValid) {\n\t\t\treturn res\n\t\t\t\t.status(401)\n\t\t\t\t.json({\n\t\t\t\t\tmessage: \"Invalid token\",\n\t\t\t\t\terror: \"Unauthorized\",\n\t\t\t\t});\n\t\t}\n\n\t\tawait markVerificationAsVerified(userId);\n\n\t\treturn res.json({\n\t\t\tmessage: \"Email worker triggered\",\n\t\t});\n\t},\n);\n\napp.get(\"/\", async (req, res) => {\n\tres.json({\n\t\tmessage: \"alive\",\n\t});\n});\n\napp.listen(PORT, () => {\n\tconsole.log(\n\t\t`🚀 Server running with Bun & Express on http://localhost:${PORT}`,\n\t);\n});\n```\n\n---\n\n## Generating the Verification Token\n\nWhen a user registers, our frontend can call:\n\n```text\nPOST /api/email-verification\n```\n\nwith:\n\n```json\n{\n\t\"userId\": \"testuser111\",\n\t\"email\": \"user@example.com\"\n}\n```\n\nThe backend generates a random token:\n\n```ts\nconst verificationToken = crypto.randomUUID();\n```\n\nWe don't store the token itself.\n\nInstead, we hash it using bcrypt:\n\n```ts\nconst hashedToken = await Bun.password.hash(\n\tverificationToken,\n\t{\n\t\talgorithm: \"bcrypt\",\n\t},\n);\n```\n\nThen we store the hash:\n\n```ts\nawait saveVerificationRecord({\n\tuserId,\n\temail,\n\thashedToken,\n});\n```\n\n---\n\n## Creating the `mailto:` Link\n\nNow we create the email that the user will send.\n\nThe token goes into the subject:\n\n```ts\nconst subject = `${userId},${verificationToken}`;\n```\n\nThen we create the body:\n\n```ts\nconst body = `\nAUTOMATED VERIFICATION EMAIL\n\nPlease send to this email without making any changes.\nDo not edit the subject or message body.\nChanges to this email may cause verification to fail.\n`;\n```\n\nAnd finally:\n\n```ts\nconst mailto =\n\t`mailto:${verificationEmail}` +\n\t`?subject=${encodeURIComponent(subject)}` +\n\t`&body=${encodeURIComponent(body)}`;\n```\n\nThe backend returns that URL to the frontend.\n\nThe frontend can use it directly:\n\n```html\n<a href=\"THE_MAILTO_URL\">\n\tVerify Email\n</a>\n```\n\nThe user's mail client opens with everything already filled in.\n\nThey press **Send**.\n\nThat's it.\n\n---\n\n## Testing the Backend\n\nBefore connecting everything to the frontend, let's test the endpoint directly.\n\nYou can use:\n\n```bash\ncurl -X POST \"https://your-backend-url/api/email-verification\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"userId\": \"testuser111\",\n    \"email\": \"user@example.com\"\n  }'\n```\n\nWhile testing locally, I used a temporary Cloudflare Tunnel to expose my Bun server:\n\n```bash\ncurl -X POST \"https://tablet-gregory-motivation-kingston.trycloudflare.com/api/email-verification\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"userId\": \"testuser111\",\n    \"email\": \"harsh@harshcodez.com\"\n  }'\n```\n\nThe backend returns a `mailto:` URL similar to:\n\n```text\nmailto: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.\n```\n\nDecoded, that's basically:\n\n```text\nTo:\nverification@hhczz.xyz\n\nSubject:\ntestuser111,57527565-018c-44a3-b2cd-f17c38215655\n\nBody:\nAUTOMATED VERIFICATION EMAIL\n\nPlease send to this email without making any changes.\nDo not edit the subject or message body.\nChanges to this email may cause verification to fail.\n```\n\nYou can open the returned `mailto:` URL in your email client and send it.\n\nThe email should then travel through:\n\n```text\nEmail Client\n    ↓\nCloudflare Email Routing\n    ↓\nCloudflare Worker\n    ↓\nBackend\n```\n\n---\n\n## Backend Verification\n\nWhen the Worker receives the email, it sends the sender and subject to:\n\n```text\nPOST /api/internal/email-worker\n```\n\nThe backend first checks the internal API key.\n\nThen it gets the `userId` and token from the subject:\n\n```ts\nconst [userId, token] = subject.split(\",\");\n```\n\nIt 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:\n\n```ts\nconst isTokenValid = await Bun.password.verify(\n\ttoken,\n\tverification.hashedToken,\n);\n```\n\nIf everything matches:\n\n```ts\nawait markVerificationAsVerified(userId);\n```\n\nThe email is verified.\n\n---\n\n## Things You Should Add Before Production\n\nThe mock implementation is intentionally simple. There are a few things I'd add before using this for a real application.\n\n### Token Expiry\n\nThe Worker checks that the email is less than 10 minutes old, but the verification token itself should also have an expiration time.\n\nStore something like:\n\n```text\nhashedToken\nexpiresAt\nverified\n```\n\nThen the backend can reject expired tokens independently of the email timestamp.\n\n### Delete Expired Records\n\nDon't keep expired verification records forever.\n\nRun a cleanup job that deletes records where:\n\n```text\nexpiresAt < current time\n```\n\nYou can also delete the record after successful verification if you don't need it for auditing.\n\n### Single-Use Tokens\n\nA token should only be usable once.\n\nThe mock implementation checks whether the record has already been verified, but in production you can also delete the verification record after successful verification.\n\n### Spam Protection\n\nThe verification endpoint is public, so someone could repeatedly request new verification tokens.\n\nAdd rate limiting based on things like:\n\n* User/account\n* Email address\n* IP address\n\nYou can also rate-limit verification attempts themselves.\n\nThis is especially important because, unlike outbound email, **your receiving address is publicly accessible**.\n\n### Protect the Worker → Backend Endpoint\n\nThe Worker sends an internal API key:\n\n```http\nx-internal-key: ...\n```\n\nKeep that key secret and store it using your platform's secret management.\n\nDon't hardcode:\n\n```ts\nconst internalAccessKey = \"random-32-char-key\";\n```\n\nin production.\n\n---\n\n## What About Spam and Domain Reputation?\n\nThis is one of the nice parts of this approach.\n\nWith something like SendGrid, you're **sending** emails to users. That means deliverability becomes part of your problem.\n\nYour emails need to reach the inbox, and your sending domain/IP reputation matters.\n\nHere, we're doing the opposite.\n\nThe user sends the email to us.\n\n```text\nUser's Email Provider\n        ↓\nverification@yourdomain.com\n        ↓\nCloudflare\n        ↓\nWorker\n        ↓\nBackend\n```\n\nSo we're not building an outbound mail server just to send a verification message.\n\nThere'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**.\n\nThe user's own mail provider is sending the message.\n\nOf 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.\n\nBut 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.\n\n---\n\n## Deploying the Worker\n\nOnce everything works locally, deploy the Worker:\n\n```bash\nnpm run deploy\n```\n\nMake sure you configure:\n\n```text\nBACKEND_URL\nINTERNAL_API_KEY\n```\n\nin your Worker environment.\n\nThen configure your Cloudflare Email Routing rule to send the verification address to your Worker.\n\n---\n\n## The Final Flow\n\nOnce everything is connected, the whole system looks like this:\n\n```mermaid\nflowchart TD\n    A([User Registers]) --> B[Backend Generates Verification Token]\n    B --> C[Hash Token & Store in Database]\n    C --> D[Create mailto Link]\n    D --> E[User Sends Verification Email]\n\n    E --> F[Cloudflare Email Routing]\n    F --> G[Cloudflare Worker Receives Email]\n\n    G --> H[Validate Email & Timestamp]\n    H --> I[Send Verification Data to Backend]\n\n    I --> J[Backend Validates Worker API Key]\n    J --> K[Find Verification Record]\n    K --> L[Check Token Expiry]\n    L --> M[Verify Token Against Bcrypt Hash]\n\n    M --> N{Token Valid?}\n\n    N -- Yes --> O([Mark Email as Verified])\n    N -- No --> P([Reject Verification])\n```\n\nSo instead of:\n\n```text\nYour Backend\n    ↓\nSendGrid\n    ↓\nEmail Infrastructure\n    ↓\nUser\n```\n\nwe have:\n\n```text\nYour Backend\n    ↓\nmailto:\n    ↓\nUser\n    ↓\nCloudflare\n    ↓\nWorker\n    ↓\nYour Backend\n```\n\nAnd that's the whole trick.\n\nYou don't need a full transactional email platform just to verify that a user controls an email address.\n\nIf you already have a domain and Cloudflare, this can be a surprisingly cheap and simple alternative for basic verification flows.\n\nJust make sure you add the production protections before using it for a real application.\n\n## Source Code\n\nThe complete Cloudflare Worker is available here:\n\n[**harshcodezzz/cloudflare-email-verification-worker**](https://github.com/harshcodezzz/cloudflare-email-verification-worker)\n\nUse it as a starting point and replace the mock backend with your own authentication system.\n",
  "author": {
    "name": "Harshcodez",
    "avatar": "https://harshcodez.com/images/logo.png",
    "bio": "Developer and Blogger"
  },
  "tags": [
    "api security",
    "backend development",
    "bun",
    "cloudflare email routing",
    "cloudflare workers",
    "email verification",
    "serverless",
    "typescript"
  ],
  "coverImage": "https://api.harshcodez.com//api/posts/26/file/Modern%20Blog%20Cover%20Cloudflare%20Workers%20Email%20Verification.png",
  "readingTime": 12,
  "publishedAt": "2026-08-10",
  "updatedAt": "2026-08-10",
  "seo": {
    "title": "Email Verification with Cloudflare Workers (FREE)",
    "description": "Ditch SendGrid for email verification. Build a serverless flow with Cloudflare Email Routing and Workers where users send the verification email themselves.",
    "keywords": [
      "api security",
      "backend development",
      "bun",
      "cloudflare email routing",
      "cloudflare workers",
      "email verification",
      "serverless",
      "typescript"
    ]
  }
}