Email Validation in Hono: Protect a Signup Route
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.