Guides
Build guide

BuildBase on Hono: authentication and billing on Workers, Bun, Deno and Node

Hono authentication with no user table: hosted sign-in, a signed-cookie session you can still revoke, and metered credits, in one app on Node, Bun, Deno and Workers.

Dharmendra Jagodana5 min read

In short

BuildBase adds authentication to a Hono app with a hosted sign-in page and a signed session cookie, so the app keeps no user table and a signed-out session stays signed out. The server SDK uses only fetch and Web APIs, so the same app runs on Node.js, Bun, Deno and Cloudflare Workers, and meters usage with credits.

Search "hono authentication" and the top results are as likely to be about Eclipse Hono, an IoT messaging project. This page is about the other one: the small web framework that runs on Cloudflare Workers, Bun, Deno and Node.js without changing a line.

That last part is the whole point of Hono, and it is the part auth usually breaks. You add a library, the library wants a database, and now your "runs anywhere" app needs a Postgres it can reach from the edge. Or you go stateless: Better Auth and Auth0's Hono SDK can both keep the session in a cookie. The catch there is revocation. Better Auth's own docs say that in stateless mode "you can't invalidate session easily".

BuildBase takes a third path. The cookie holds only a BuildBase session ID, and BuildBase checks it on every request. There is no user table in your app, and a session revoked anywhere is signed out everywhere. The example is about 400 lines, and we signed into it separately on all four runtimes against a BuildBase org on a local stack.

Before you start

A Hono app, a BuildBase organization, and an auth client created in the console under User Management → Authentication, with its client ID and secret. Register http://localhost:3000/auth/callback on the client. On localhost the port is ignored, so the same entry covers wrangler dev on 8787; in production, register each deployment's /auth/callback.

Tip

Start from the working example. hono in the examples repo runs everything on this page on all four runtimes. Watch it working, or copy it with npx degit buildbase-app/examples/hono my-app.

1. Four variables, read the same way on every runtime

npm i hono @buildbase/sdk
# .env (Node, Bun, Deno) and .dev.vars (Workers)
BUILDBASE_ORG_ID=demo-org-id
BUILDBASE_CLIENT_ID=demo-client-id
BUILDBASE_CLIENT_SECRET=demo-client-secret
COOKIE_SECRET=a-long-random-string   # openssl rand -hex 32

Every runtime keeps its environment somewhere different: process.env, Bun.env, Deno.env, or the Worker's env argument, which only exists inside a request. env() from hono/adapter reads whichever applies, so the app never has to know:

import { env } from 'hono/adapter';

const config = (c: Context) => env<Env>(c);
const secure = (c: Context) => new URL(c.req.url).protocol === 'https:';

Same reason the SDK client below is created on first use: at import time, a Worker has no config to give it.

2. The BuildBase calls, behind one function

// src/buildbase.ts
import { ApiVersion, AuthApi, BuildBase } from '@buildbase/sdk';

const clients = new Map<string, ReturnType<typeof BuildBase>>();

export function buildbaseFor(env: Env) {
  const serverUrl =
    env.BUILDBASE_SERVER_URL || 'https://api.console.buildbase.app';
  const orgId = env.BUILDBASE_ORG_ID ?? '';
  const authApi = new AuthApi({ serverUrl, version: ApiVersion.V1 });
  const forSession = (sessionId: string) => {
    let client = clients.get(orgId);
    if (!client) clients.set(orgId, (client = BuildBase({ serverUrl, orgId })));
    return client.withSession(sessionId);
  };

  return {
    signInUrl: async (state: string, callbackUrl: string) =>
      (
        await authApi.requestAuth({
          orgId,
          clientId: env.BUILDBASE_CLIENT_ID ?? '',
          redirect: { success: callbackUrl, error: callbackUrl },
          state,
        })
      ).redirectUrl,

    exchangeCode: async (code: string) => {
      const res = await fetch(`${serverUrl}/api/v1/auth/token`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          code,
          orgId,
          clientId: env.BUILDBASE_CLIENT_ID,
          clientSecret: env.BUILDBASE_CLIENT_SECRET,
        }),
      });
      if (!res.ok)
        throw new Error(`BuildBase token exchange failed (${res.status})`);
      return ((await res.json()) as { data: { sessionId: string } }).data
        .sessionId;
    },

    account: async (sessionId: string) => {
      const bb = forSession(sessionId);
      const [profile, [workspace]] = await Promise.all([
        bb.users.getProfile(),
        bb.workspace.list(),
      ]);
      const balance = workspace
        ? await bb.credits.getBalance(workspace._id)
        : null;
      return { profile, workspace, credits: balance?.available ?? null };
    },

    spend: async (sessionId: string, idempotencyKey: string) => {
      const bb = forSession(sessionId);
      const [workspace] = await bb.workspace.list();
      return bb.credits.consume(workspace._id, { amount: 1, idempotencyKey });
    },

    revoke: (sessionId: string) => authApi.logout(sessionId).catch(() => {}),
  };
}

Nothing here imports from Node. The SDK's root export sticks to fetch and other Web APIs, which is what lets one file run on all four runtimes.

Every snippet on this page is trimmed from the example, and the example is the tested version. Its buildbase.ts is a little longer: it keys the client cache on the server URL too, and returns plain objects rather than SDK types.

// src/app.tsx
import { Hono } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';

app.get('/auth/sign-in', async (c) => {
  const cfg = config(c);
  const state = crypto.randomUUID();
  await setSignedCookie(c, 'bb_state', state, cfg.COOKIE_SECRET!, {
    httpOnly: true,
    secure: secure(c),
    sameSite: 'Lax',
    path: '/auth',
    maxAge: 600,
  });
  const callback = `${cfg.APP_URL || new URL(c.req.url).origin}/auth/callback`;
  return c.redirect(await buildbaseFor(cfg).signInUrl(state, callback));
});

app.get('/auth/callback', async (c) => {
  const cfg = config(c);
  const { code, state } = c.req.query();
  const expected = await getSignedCookie(c, cfg.COOKIE_SECRET!, 'bb_state');
  deleteCookie(c, 'bb_state', { path: '/auth' });
  if (!code || !state || state !== expected)
    return c.redirect('/?error=sign-in-not-verified');

  const sessionId = await buildbaseFor(cfg).exchangeCode(code);
  await setSignedCookie(c, 'bb_session', sessionId, cfg.COOKIE_SECRET!, {
    httpOnly: true,
    secure: secure(c),
    sameSite: 'Lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7,
  });
  return c.redirect('/');
});

Why sign the cookie when BuildBase checks the ID anyway? So that a forged or edited value is thrown away by getSignedCookie before the app spends a network call on it. Click Sign in with BuildBase, and the hosted page offers whichever of the eight sign-in methods your org has on; you come back to / signed in.

4. Every page asks BuildBase, which is what makes revocation work

const account = await buildbaseFor(config(c))
  .account(sessionId)
  .catch((err) => {
    if (err.status !== 401) throw err;
    deleteCookie(c, 'bb_session', { path: '/' }); // revoked elsewhere
    return null;
  });

That catch is the difference from a purely stateless session. Sign out on another device, or revoke the session from BuildBase, and the next request here gets a 401, clears the cookie and renders the signed-out page. The example has a test for exactly that.

You pay for it in round trips to BuildBase on each page that needs the account: the profile and the workspace list together, then the balance, so two in a row. On Workers that call goes from the edge to BuildBase's API. Without BuildBase it would go to a database you run.

5. A metered endpoint that answers 402

import { csrf } from 'hono/csrf';

app.use(csrf());

app.post('/api/spend', async (c) => {
  const sessionId = await getSignedCookie(
    c,
    config(c).COOKIE_SECRET!,
    'bb_session'
  );
  if (!sessionId) return c.json({ message: 'Sign in first.' }, 401);
  try {
    const key = c.req.header('Idempotency-Key') || crypto.randomUUID();
    return c.json(await buildbaseFor(config(c)).spend(sessionId, key));
  } catch (err) {
    if ((err as { code?: string }).code === 'INSUFFICIENT_CREDITS') {
      return c.json({ message: 'Out of credits.' }, 402);
    }
    throw err;
  }
});

Send the same Idempotency-Key twice and you are charged once. Empty the workspace and you get a 402 your client can turn into a buy-more prompt. For the pricing side of this, see charging per API call and a credits wallet; both are written for Next.js, and from the SDK call down they are the same code.

csrf() refuses cross-site form posts, and that includes a POST with no content type. So API callers send Content-Type: application/json, which another site cannot do without a CORS preflight.

6. Short entry files, one per runtime

// src/node.ts
import { serve } from '@hono/node-server';

import { createApp } from './app.tsx';

serve({ fetch: createApp().fetch, port: Number(process.env.PORT ?? 3000) });

// src/bun.ts
export default {
  port: Number(process.env.PORT ?? 3000),
  fetch: createApp().fetch,
};

// src/deno.ts
Deno.serve({ port: Number(Deno.env.get('PORT') ?? 3000) }, createApp().fetch);

// src/worker.ts
export default createApp();

Run npm run dev, npm run dev:bun, npm run dev:deno or npm run dev:workers, and the page tells you which runtime answered. On each of the four we signed in and spent credits. The Worker bundle comes to about 160 KiB, 39 KiB gzipped, and needs no nodejs_compat flag.

Key takeaway

On Hono, BuildBase is a signed cookie holding a session ID. No user table, no Node APIs, and a revoked session stops working on the next request.

Where the alternatives are the better fit

Better Auth is free and open source, and you own every table. If you already run a database your edge can reach, or you want no vendor in the request path at all, it is the better choice. Its stateless mode also works with no database, as long as you can live with the revocation limit above.

Auth0's @auth0/auth0-hono is less code: app.use('*', auth0()) gives you login, logout, callback and session. Its July 2026 launch post calls it beta, with Node and Workers as the fully supported runtimes and Bun and Deno as best-effort. This example goes one step further than sign-in: workspaces and credits from the same session.

What breaks, and how you can tell

Sign-in answers 503. A required variable is missing. The example lists them on the home page: BUILDBASE_ORG_ID, BUILDBASE_CLIENT_ID, BUILDBASE_CLIENT_SECRET and COOKIE_SECRET.

POST /api/spend returns 403 from curl. The CSRF guard saw a body-less POST. Add -H 'Content-Type: application/json'.

Signed in on Node, signed out on Workers. Different COOKIE_SECRETs, so one runtime cannot verify the other's cookie. In production each deployment has its own domain anyway; locally, share one secret.

The hosted page refuses to send you back. The callback URL must match one registered on the auth client exactly, path included. On localhost the port is ignored, so one entry covers Node on 3000 and wrangler dev on 8787; anywhere else it must match too. A wildcard can stand in for one leftmost subdomain on a domain you own, but not for a public suffix like *.vercel.app or *.workers.dev.

Spends fail with 429 under load. Credit consumption is limited to 30 requests a minute per IP, and Workers share outgoing IPs, so a busy Worker can reach it sooner than a single server would.

Install

Add the SDK to your own app, or start from the example with npx degit buildbase-app/examples/hono my-app.

npm i hono @buildbase/sdk
npm i @hono/node-server   # only for the Node entry file
hono
cloudflare-workers
auth

Frequently Asked Questions

Does Hono have built-in authentication?

Only primitives. Its Basic, Bearer and JWT middleware check credentials you already have, but there is no built-in sign-in flow or user accounts. For those you add a library such as Better Auth or a provider such as BuildBase or Auth0.

Can I run Hono authentication on Cloudflare Workers without a database?

Yes. Keep the session in a signed cookie and let the provider hold the accounts. BuildBase does this and still lets you revoke a session, because the cookie only carries a session ID that BuildBase checks. The example Worker bundles to about 39 KiB gzipped.

Does the same Hono auth code work on Bun and Deno?

With BuildBase, yes. Only a short entry file changes per runtime, and configuration comes through env() from hono/adapter, which reads process.env, Bun.env, Deno.env or the Worker bindings as appropriate.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime