Skip to content

Recipes

These are the patterns that show up in the canonical examples. Each language has one application example instead of parallel copies of every app. The code here shows all languages where comparison helps, trimmed to the Forge calls so the shape is easy to lift into your own handlers.

The deployment recipes live beside their operational guarantees: migration jobs, managed workers and graceful shutdown, transactional outbox delivery, liveness and readiness endpoints, and Prometheus export. Queue producers should supply a deterministic job ID for a business operation and make handlers safe to repeat; use the outbox when enqueue must agree with an application-owned SQL transaction. For blob uploads, use the Forge proxy signature when the server must enforce a hard body limit or application-specific authorization, and native S3 presigning when direct provider transfer is acceptable.

Email and password auth is about thirty lines of Forge calls per handler: hash on the way in, verify on the way out, and issue sessions you can revoke. There’s no separate users table to wire up first: kv stores the user record and auth owns the hashing and the sessions.

Sign-up does four things: throttle by email, hash the password, claim the email atomically, and open a session. Writing the email key with “set if not present” makes it its own uniqueness check, so there’s no read-then-write race.

// throttle by email, fail open so a limiter outage doesn't lock people out
const limit = await forge.rateLimitCheck("auth", email, 20, 60, true);
if (!limit.allowed) return c.json({ error: "slow down" }, 429);
const user = { id: randomUUID(), email, passwordHash: await forge.hashPassword(password) };
const body = JSON.stringify(user);
// the email key is its own uniqueness guard: false means already taken
const created = await forge.kvSet(`user:email:${email}`, body, null, true);
if (!created) return c.json({ error: "email already registered" }, 409);
await forge.kvSet(`user:id:${user.id}`, body);
const token = await forge.createSession(user.id, 86_400, 30 * 86_400);

Logging in runs the same throttle, verifies the password against the stored hash, then opens a fresh session. Keep the rate-limit check ahead of verifyPassword so a stuffing run can’t burn CPU on hashing.

const limit = await forge.rateLimitCheck("auth", email, 20, 60, true);
if (!limit.allowed) return c.json({ error: "slow down" }, 429);
const stored = await forge.kvGet(`user:email:${email}`);
if (!stored) return c.json({ error: "invalid email or password" }, 401);
const user = JSON.parse(stored);
// the throttle runs before verifyPassword, so credential stuffing can't burn CPU on hashing
if (!(await forge.verifyPassword(password, user.passwordHash)))
return c.json({ error: "invalid email or password" }, 401);
const token = await forge.createSession(user.id, 86_400, 30 * 86_400);

Every protected route resolves the caller’s token to a user (or 401s), and logout revokes the session so the token stops working immediately.

// middleware: resolve the bearer token (or session cookie) to a user id, else 401
const userId = await forge.validateSession(bearerToken(c.req.header("authorization")));
if (!userId) return c.json({ error: "authentication required" }, 401);
// logout: revoke the session so the token stops resolving
await forge.revokeSession(bearerToken(c.req.header("authorization")));

Password reset is a one-time token plus an email your app sends. Mint a short-lived token scoped to "password-reset", put it in the link, and consume it when the user comes back; the first consume wins and every retry after that returns nothing. Respond identically whether or not the email exists so the endpoint doesn’t confirm accounts.

// request handler: always answer 200; only mint + email when the account exists
const found = await forge.kvGet(`user:email:${email}`);
if (found) {
const user = JSON.parse(found);
const token = await forge.createToken(user.id, "password-reset", 900); // 15 min
await forge.queue("emails").enqueue({ to: email, resetLink: linkFor(token) });
}
// reset handler: consume exactly once, then re-hash and revoke every session
const consumed = await forge.consumeToken(token, "password-reset");
if (!consumed) return c.json({ error: "link expired" }, 400);
await updatePasswordHash(consumed.userId, await forge.hashPassword(newPassword));
await forge.revokeAllSessions(consumed.userId);

The same shape does email verification ("email-verify" purpose, consume on the confirmation link) and magic-link login (consume, then create_session for the returned user id). The purpose string is what stops a reset token from being replayed against the verify endpoint.

Keep the request fast by doing only the cheap part inline and handing the rest to a queue. The URL shortener bumps a click counter on the redirect and queues everything else, so the user gets their redirect immediately.

// in the redirect handler: cheap now, the rest later
await forge.kvIncr(`clicks:${slug}`, 1);
await forge.queue<{ slug: string }>("clicks").enqueue({ slug }, { maxAttempts: 3 });

A worker leases jobs, does the work, and acks on success or nacks on failure so the job is retried. Run one of these per queue (see Operations for a managed version).

await forge.worker<{ slug: string }>("clicks", async (job) => {
await handle(forge, job.payload);
});

Publish backend events through an application transport

Section titled “Publish backend events through an application transport”

The worker that drains the clicks queue publishes a bounded invalidation hint after the durable count changes. An application-owned SSE route subscribes to that topic and forwards each hint to a connected client, then drops the subscription when the client disconnects. The client refetches the count; it never treats the hint as authoritative state. WebSockets or another transport can use the same backend pattern.

// in the worker handler, after committing the new count
await forge.pubsubPublish(`clicks:${slug}`, encodeInvalidationEvent({
schema_version: 1, tags: [`links/${slug}`],
query_keys: [["link", slug]], revision: String(total),
}).toString("utf8"));
// in the SSE route (Hono)
return streamSSE(c, async (stream) => {
const events = await forge.topic<unknown>(`clicks:${slug}`)
.subscribe();
stream.onAbort(() => { void events.return?.(); });
for await (const event of events) {
await stream.writeSSE({ data: JSON.stringify(event) });
}
});

Put generated or uploaded bytes under a key with a content type, read them back to serve them, or hand the client a presigned link so it can fetch the file directly.

await forge.blobPut(`qr/${slug}.svg`, svg, "image/svg+xml");
const stored = await forge.blobGet(`qr/${slug}.svg`); // string | null
const url = (await forge.blobPresignDownload(`qr/${slug}.svg`, 3600)).url;

Presigned links need a signing_secret set in forge.toml. For uploads, hand back the .url from the structured proxy presign result; the application proxy verifies its method, key, expiry, namespace, and max_bytes before accepting the body.

Schedule a one-shot for delayed work, like expiring a link at its TTL, or a cron for recurring jobs. Both land on a normal queue, so the same workers drain them.

// expire this link in an hour
await forge.scheduleAt(Date.now() + 3600_000, "expire", JSON.stringify({ slug }));
// a daily report
await forge.scheduleCron("nightly-report", "0 0 * * *", "reports", "{}");

Schedules only fire when the scheduler is ticked. The same loop that ticks it is a good place to run Forge’s maintenance sweep. See Operations for the loop these apps run on every replica.