Hono on Cloudflare Workers is a natural home for a signup endpoint: it is small, close to users, and has first-class middleware. It is also a place where signup abuse checks are easy to bolt on badly, by reading secrets from the wrong place, blocking the request on a slow network call, or linking accounts before they exist.
This post builds the standard three-step signup recipe as Hono middleware. The browser identifies the device and sends a requestId; the Worker looks up the event with your secret key and decides; after the user is created, the Worker attaches your user id to the event so future signups see it.
Secrets live in bindings
On Workers, environment variables and secrets arrive as bindings on c.env, not on process.env. Store the secret key with Wrangler:
npx wrangler secret put PRYNT_SECRET_KEY # sk_live_…
npx wrangler secret put PRYNT_PROXY_SECRET # optional, for the first-party proxy below
For local development, put the same names in .dev.vars, which wrangler dev reads and which belongs in .gitignore. Then type the bindings so Hono knows about them:
import { Hono } from 'hono';
import { createMiddleware } from 'hono/factory';
type Bindings = {
PRYNT_SECRET_KEY: string;
PRYNT_PROXY_SECRET?: string;
DB: D1Database;
};
type Verdict = {
action: 'allow' | 'flag' | 'block';
requestId: string | null;
visitorId: string | null;
};
const app = new Hono<{ Bindings: Bindings; Variables: { prynt: Verdict } }>();
const API = 'https://api.pryntid.com';
The middleware
The browser sends the requestId in a header. A header is easier than a body field here because middleware can read it without parsing or consuming the request body.
const MAX_ACCOUNTS_PER_DEVICE = 1;
const deviceCheck = createMiddleware<{ Bindings: Bindings; Variables: { prynt: Verdict } }>(
async (c, next) => {
const requestId = c.req.header('X-Prynt-Request-Id') ?? null;
if (!requestId) {
c.set('prynt', { action: 'flag', requestId: null, visitorId: null });
return next();
}
let event: any = null;
try {
const res = await fetch(`${API}/v1/events/${encodeURIComponent(requestId)}`, {
headers: { Authorization: `Bearer ${c.env.PRYNT_SECRET_KEY}` },
signal: AbortSignal.timeout(3000),
});
if (res.status === 404) {
c.set('prynt', { action: 'flag', requestId: null, visitorId: null });
return next();
}
if (res.ok) event = await res.json();
} catch {
// timeout or network error: fail open, never take signup down
}
if (!event) {
// keep the requestId so the account can still be linked once Prynt is reachable
c.set('prynt', { action: 'allow', requestId, visitorId: null });
return next();
}
const tooMany = (event.accountsOnDevice?.count ?? 0) >= MAX_ACCOUNTS_PER_DEVICE;
const reused = Boolean(event.linkedId);
if (event.decision === 'block' || tooMany || reused) {
return c.json({ error: "We couldn't create an account from this device." }, 403);
}
c.set('prynt', {
action: event.decision === 'challenge' ? 'flag' : 'allow',
requestId,
visitorId: event.visitorId,
});
await next();
},
);
Three details matter.
decision is a string on the server. The browser’s identify() result carries decision as an object with decision, riskScore and reasonCodes. GET /v1/events/{requestId} returns decision as a plain string and riskScore as a top-level integer. Mixing the two shapes is a common bug when code is copied between client and server.
The reused check. A requestId is not single-use and does not expire. If the event already carries a linkedId, that identification has been used for another account, and accepting it again would let one clean identification sign up any number of users.
Unknown vs unavailable. A 404 means the requestId is not from your environment, a sign of a forged or copied value, so it is flagged. A timeout means Prynt was unreachable, and the user gets the benefit of the doubt. A secret key only sees its own environment, so a test-mode requestId against a live key also returns 404; worth remembering while debugging.
The route, and linking with waitUntil
app.post('/api/signup', deviceCheck, async (c) => {
const { email, password } = await c.req.json<{ email: string; password: string }>();
const verdict = c.get('prynt');
const userId = crypto.randomUUID();
await c.env.DB.prepare(
'INSERT INTO users (id, email, password_hash, review_flag, signup_request_id) VALUES (?, ?, ?, ?, ?)',
).bind(userId, email, await hashPassword(password), verdict.action === 'flag' ? 1 : 0, verdict.requestId)
.run();
if (verdict.requestId) {
c.executionCtx.waitUntil(
fetch(`${API}/v1/events/${encodeURIComponent(verdict.requestId)}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${c.env.PRYNT_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ linkedId: userId }),
}),
);
}
return c.json({ ok: true, userId }, 201);
});
The link runs after the insert succeeds, because a failed insert should not count against the device. waitUntil lets the Worker return the response immediately while the PUT finishes in the background. If you need the link to be guaranteed rather than best effort, push it to a Queue with the stored signup_request_id and retry from a consumer.
Use your own user id as linkedId, not the email address. It must be a non-empty string of up to 190 characters, and an opaque id keeps personal data out of the device record.
The browser side
On the signup page, identify on submit and pass the requestId in the header the middleware reads:
const prynt = await Prynt.load({ apiKey: 'pk_live_…' });
async function submitSignup(email, password) {
const headers = { 'Content-Type': 'application/json' };
try {
const { requestId } = await prynt.identify({ tag: { action: 'signup' } });
headers['X-Prynt-Request-Id'] = requestId;
} catch {
// agent blocked or offline: send without it, the middleware flags the signup
}
return fetch('/api/signup', {
method: 'POST',
headers,
body: JSON.stringify({ email, password }),
});
}
Optional: a first-party proxy on the same Worker
Some blockers strip third-party fingerprinting scripts. Serving the agent and its API under your own domain avoids that, and since you already have a Worker on the domain, it costs two routes:
app.get('/__prynt/agent.js', async () => {
const r = await fetch(`${API}/cdn/prynt.umd.js`, { cf: { cacheEverything: true, cacheTtl: 3600 } });
return new Response(r.body, {
headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=3600' },
});
});
app.all('/__prynt/*', async (c) => {
const url = new URL(c.req.url);
const rest = url.pathname.slice('/__prynt/'.length).replace(/^v1\//, '');
const fwd = new Request(`${API}/v1/${rest}${url.search}`, c.req.raw);
const ip = c.req.header('CF-Connecting-IP');
if (c.env.PRYNT_PROXY_SECRET && ip) {
fwd.headers.set('X-Prynt-Proxy-Secret', c.env.PRYNT_PROXY_SECRET);
fwd.headers.set('X-Prynt-Client-IP', ip);
}
return fetch(fwd);
});
Then load the agent same-origin with <script src="/__prynt/agent.js"> and Prynt.load({ apiKey: 'pk_live_…', endpoint: location.origin + '/__prynt' }).
The proxy secret is the important part. Without it, every identification reaches Prynt from Cloudflare’s IP, and IP intelligence, geolocation and velocity all describe your Worker instead of the visitor. With it, Prynt accepts X-Prynt-Client-IP because the secret matches; anyone else sending that header is ignored. Generate it in the console under API Keys, then First-party proxy. The first-party serving guide covers caching and the nginx equivalent.
Where edge gating fits
This middleware protects one route. If you also want to stop bots before any page renders, the edge connector does allow, challenge or block at the Worker for whole paths; see edge bot detection on Cloudflare. The two compose: gate navigation at the edge, enforce account limits at signup.
For how to choose the limit itself, read device-based signup limits. Every field used above is in the docs, and the free plan covers enough identifications to run this in production on a young product. Deploy with the limit set high, read a week of logs, then tighten.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.