<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[UsageRail Engineering]]></title><description><![CDATA[UsageRail Engineering]]></description><link>https://usagerail.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 08:56:43 GMT</lastBuildDate><atom:link href="https://usagerail.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[API keys and per-customer rate limits for a Hono API]]></title><description><![CDATA[A working middleware in about 40 lines, what it leaves out, and when to stop writing it yourself.]]></description><link>https://usagerail.hashnode.dev/api-keys-and-per-customer-rate-limits-for-a-hono-api</link><guid isPermaLink="true">https://usagerail.hashnode.dev/api-keys-and-per-customer-rate-limits-for-a-hono-api</guid><category><![CDATA[api]]></category><category><![CDATA[rate-limiting]]></category><category><![CDATA[hono]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Super Ma]]></dc:creator><pubDate>Sat, 07 Nov 2026 10:52:07 GMT</pubDate><content:encoded><![CDATA[<p>Once a Hono API has outside callers, two questions come up quickly: who is calling, and how often are they allowed to. This post builds a middleware that answers both, then goes through what it doesn't handle.</p>
<h2>The middleware</h2>
<pre><code class="language-ts">import { Hono } from "hono";
import { createMiddleware } from "hono/factory";

type Customer = { id: string; perMinute: number };

// SHA-256 of each issued key -&gt; customer. Store hashes, never the raw keys.
const keys = new Map&lt;string, Customer&gt;();

async function sha256Hex(value: string): Promise&lt;string&gt; {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
  return [...new Uint8Array(digest)].map((b) =&gt; b.toString(16).padStart(2, "0")).join("");
}

// One fixed one-minute window per customer, kept in memory.
const windows = new Map&lt;string, { start: number; count: number }&gt;();

const apiKeyAuth = createMiddleware&lt;{ Variables: { customer: Customer } }&gt;(async (c, next) =&gt; {
  const header = c.req.header("authorization") ?? "";
  const key = header.startsWith("Bearer ") ? header.slice(7) : c.req.header("x-api-key");
  if (!key) return c.json({ error: "missing_api_key" }, 401);

  const customer = keys.get(await sha256Hex(key));
  if (!customer) return c.json({ error: "invalid_api_key" }, 401);

  const now = Date.now();
  const windowStart = now - (now % 60_000);
  const previous = windows.get(customer.id);
  const current = previous &amp;&amp; previous.start === windowStart ? previous : { start: windowStart, count: 0 };
  if (current.count &gt;= customer.perMinute) {
    const retryAfter = Math.ceil((windowStart + 60_000 - now) / 1000);
    return c.json({ error: "rate_limited" }, 429, { "Retry-After": String(retryAfter) });
  }
  current.count += 1;
  windows.set(customer.id, current);

  c.set("customer", customer);
  await next();
});

const app = new Hono&lt;{ Variables: { customer: Customer } }&gt;();
app.use("/v1/*", apiKeyAuth);
app.get("/v1/me", (c) =&gt; c.json({ customer: c.get("customer").id }));
</code></pre>
<p>Callers send their key as <code>Authorization: Bearer &lt;key&gt;</code>, or in <code>x-api-key</code>. The middleware hashes the key, looks up the customer, and counts the request against that customer's limit for the current minute.</p>
<p>A few choices worth keeping even if you change everything else:</p>
<ul>
<li><strong>Store hashes, not keys.</strong> If the key table leaks, a SHA-256 of a long random key is useless to whoever has it. Show the raw key to the customer once, when you create it.</li>
<li><strong>Limit per customer, not per IP.</strong> A customer running jobs from a few servers shares one budget, and two customers behind the same NAT don't throttle each other.</li>
<li><strong>Send</strong> <code>Retry-After</code> <strong>with the</strong> <code>429</code><strong>.</strong> Well-behaved clients wait for it instead of retrying immediately.</li>
<li><strong>Return</strong> <code>401</code> <strong>for a missing or unknown key.</strong> Keep <code>403</code> for a key you recognise but whose customer isn't allowed in, such as a suspended account.</li>
</ul>
<p>I ran it against Hono 4.13 with a limit of 2 per minute: no key got <code>401</code>, an unknown key got <code>401</code>, the first two calls got <code>200</code>, and the third got <code>429</code> with a <code>Retry-After</code> header.</p>
<h2>What this version leaves out</h2>
<p>It works on one process. Past that, the gaps show up quickly:</p>
<ul>
<li><strong>More than one instance.</strong> Each process has its own <code>windows</code> map, so three instances let a customer make three times their limit. You need a shared counter (Redis, or your database) or a limit divided by the number of instances.</li>
<li><strong>Burst at the edge of a window.</strong> A fixed window lets a customer send their full limit at 12:00:59 and again at 12:01:00. A sliding window or token bucket smooths that out.</li>
<li><strong>Key management.</strong> Customers need to create, rotate and revoke their own keys, and a revoked key has to stop working everywhere, not only after a restart.</li>
<li><strong>Monthly quotas.</strong> "1,000 requests per minute" and "100,000 requests per month" are different counters. The monthly one has to survive restarts and decide what happens at the limit: stop, or bill the overage.</li>
<li><strong>Billing.</strong> If customers pay by usage, the counts have to reach your invoices without losing any or counting any twice.</li>
</ul>
<p>Each of these is a reasonable afternoon on its own. Together they're a small product.</p>
<h2>The same thing with UsageRail</h2>
<p>I work on UsageRail, which is that product for teams that already bill with Stripe. Its Hono middleware checks the key, rate limit and quota inside your process, so requests never go through a proxy, and counts usage per customer:</p>
<pre><code class="language-ts">import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { usageRail, type UsageRailVariables } from "@usagerail/node/hono";
import { fileSnapshotStore, flushOnExit } from "@usagerail/node/node";

const appKey = process.env.USAGERAIL_APP_KEY!;
const rail = usageRail({
  appKey,
  auth: "api-key",
  snapshotStore: fileSnapshotStore(appKey),
});
flushOnExit(rail.engine);

const app = new Hono&lt;{ Variables: UsageRailVariables }&gt;();
app.use("*", rail);

app.post("/v1/generate", async (c) =&gt; {
  const result = await generate(await c.req.json());
  c.get("usageRail").track(result.tokens); // optional: count tokens
  return c.json(result);
});

serve({ fetch: app.fetch, port: 3000 });
</code></pre>
<p>Limits, quotas and plans are set in a console instead of code, and your customers manage their own keys in a hosted portal. On Node.js, <code>flushOnExit</code> matters: without it, counts that haven't been sent when the process stops are lost and never billed. The details, including Hono on Cloudflare Workers, are in the docs on <a href="https://usagerail.com/docs/hono">metering and rate limits for Hono APIs</a>.</p>
<p>If you only need API keys and a per-customer rate limit in one process, keep the first version.</p>
]]></content:encoded></item></channel></rss>