emailverifier.dev
Posts

Email Validation in Hono: Protect a Signup Route

| 4 min read | Usama Ejaz
Hono signup validation cover with a Worker boundary and one email request.

Hono can run the same signup route on several JavaScript runtimes. On Cloudflare Workers, the important boundary is c.env: the API key is a server binding, while the browser submits only the address it wants to use.

The project below makes one verification request, returns a small application decision, and uses Hono’s request helper to test the route with a mock binding and fetch function.

Define the runtime boundary

browser JSON        Worker binding             outbound request
{"email":"..."} -> EMAILVERIFIER_API_KEY -> POST /api/v1/verify
                       never returned             one credit

Hono exposes Cloudflare environment variables and secrets through typed bindings on c.env. Its testing guide passes a binding object as the third argument to app.request(), which keeps the example independent of a deployed Worker.

Create the Hono Worker

npm create hono@latest hono-email-signup
cd hono-email-signup
npm install
npm install --save-dev vitest

Select the Cloudflare Workers template. Create src/app.ts:

import { Hono } from 'hono'

type Bindings = {
  EMAILVERIFIER_API_KEY: string
}

type VerificationResult = {
  email: string
  status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
  action: 'allow' | 'review' | 'block'
  flagged: boolean
  signals: string[]
  suggestion: string | null
}

type Fetch = typeof fetch

const emailPattern = /^[^\s@]+@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i

export function createApp(fetchImpl: Fetch = fetch) {
  const app = new Hono<{ Bindings: Bindings }>()

  app.post('/signup', async (c) => {
    const payload = await c.req.json().catch(() => null)
    const email = typeof payload?.email === 'string'
      ? payload.email.trim().toLowerCase()
      : ''

    if (email.length > 254 || !emailPattern.test(email)) {
      return c.json({
        decision: 'invalid_request',
        message: 'Send a valid email address.'
      }, 400)
    }

    let response: Response
    try {
      response = await fetchImpl(
        'https://emailverifier.dev/api/v1/verify',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': c.env.EMAILVERIFIER_API_KEY
          },
          body: JSON.stringify({ email }),
          signal: AbortSignal.timeout(4000)
        }
      )
    } catch {
      return c.json({
        decision: 'retry',
        message: 'Email verification is temporarily unavailable.'
      }, 503)
    }

    if (!response.ok) {
      return c.json({
        decision: 'retry',
        message: 'Email verification is temporarily unavailable.'
      }, 503)
    }

    const report = await response.json() as VerificationResult

    if (report.action === 'block') {
      return c.json({
        decision: 'block',
        message: report.suggestion
          ? 'Check the spelling of your email address.'
          : 'Use a different email address.',
        signals: report.signals,
        suggestion: report.suggestion
      }, 422)
    }

    if (report.action === 'review') {
      return c.json({
        decision: 'review',
        message: 'Confirm this address before receiving full access.',
        signals: report.signals
      }, 202)
    }

    // Create the account after this point.
    return c.json({
      decision: 'allow',
      message: 'Continue to account creation.',
      signals: report.signals
    }, 200)
  })

  return app
}

Create src/index.ts:

import { createApp } from './app'

export default createApp()

The route’s only external call is the single fetchImpl(). A transport error and a non-2xx upstream response both become 503 retry, not a block.

Test the Worker without a live API key

Create src/app.test.ts:

import { describe, expect, it, vi } from 'vitest'
import { createApp } from './app'

const env = { EMAILVERIFIER_API_KEY: 'test-project-key' }

describe('POST /signup', () => {
  it('blocks a disposable result after one request', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({
      email: 'person@example.com',
      status: 'risky',
      action: 'block',
      flagged: true,
      signals: ['disposable_address'],
      suggestion: null
    }), { status: 200 }))

    const response = await createApp(fetchMock as typeof fetch).request(
      '/signup',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: 'person@example.com' })
      },
      env
    )

    expect(response.status).toBe(422)
    expect(fetchMock).toHaveBeenCalledOnce()
    expect(fetchMock.mock.calls[0][1]?.headers).toEqual({
      'Content-Type': 'application/json',
      'X-API-Key': 'test-project-key'
    })
  })

  it('does not call the API for malformed input', async () => {
    const fetchMock = vi.fn<typeof fetch>()
    const response = await createApp(fetchMock as typeof fetch).request(
      '/signup',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: 'not-an-email' })
      },
      env
    )

    expect(response.status).toBe(400)
    expect(fetchMock).not.toHaveBeenCalled()
  })
})

Add a test script to package.json:

{
  "scripts": {
    "dev": "wrangler dev",
    "test": "vitest run",
    "deploy": "wrangler deploy"
  }
}

Keep the dependencies generated by the Hono template. Run npm test.

Add the secret and make the request

Create an account and project, then copy the API key. Store it as a Worker secret:

npx wrangler secret put EMAILVERIFIER_API_KEY
npm run dev

For local development, Wrangler will prompt for or load the secret according to your Workers configuration. Never put the key under vars in a committed Wrangler file.

curl http://localhost:8787/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"person@example.com"}'

Your route returns 400, 422, 202, 200, or 503. The verification result can be deliverable, risky, undeliverable, or unknown; the application decision remains allow, review, or block.

If you only need a domain-level classification, the existing Cloudflare Worker domain-check guide uses the smaller domain endpoint. This Hono route owns full-address signup verification.

Continue reading