Storyie's mobile app never touches the database directly. Every read and write from the Expo client goes through a Hono API mounted inside Next.js at /api/v1/*. That API now has around twenty sub-routers — account, tags, diaries, notes, sync, AI assist, and more — and it keeps growing.
Here is the failure mode we were worried about: a developer adds route number twenty-one, wires up the handler, ships it, and forgets the one line that checks the caller is signed in. Nothing fails. Tests pass. The endpoint just quietly serves private data to anyone who knows the URL. Per-route authentication is a forgetting machine, and the thing you forget is the security boundary.
This post is about how we removed that failure mode: the /api/v1 API is protected by default and fails closed. A new route is locked unless you explicitly open it, rate limiting runs before auth on purpose, and every rejection speaks one error language. None of this was our first design — it started as a code-review finding.
TL;DR
- Auth is global, not per-route.
app.use("*", authMiddleware)guards every route; individual route files contain no auth call and just readc.get("user"). - Public is an allowlist. A
PUBLIC_PATHSSet of exact paths is the only way to make a route public. Forget to open a route and it stays locked (401), not exposed. - Rate limiting runs before auth so brute-force is throttled before any auth DB query, and the two concerns stay independent.
- One error envelope —
{ error, code, message }— defined once as a Zod schema in a shared contract package, so server/client drift is a compile error. - A regression test pins it: an unauthenticated request to an unknown path must return
401 AUTH_FAILED. That test is the guarantee, not the comment. - Why: the original boundary was registration-order dependent; a reviewer flagged it, and we re-architected it to be order-independent and default-deny.
The danger of opt-in auth
The tempting design is to add an auth check inside each route, or to a group of routes. It reads fine in review — every handler you look at has its guard. The problem is the handler you don't look at, the one that doesn't exist yet. With opt-in auth, the default state of a new route is "public," and you are relying on every future contributor (including yourself, six months from now, at 11pm) to remember to flip it.
The safer default is the inverse: authentication applies to everything, and making something public is the deliberate, visible act. That way forgetfulness produces a locked door, not an open one. The rest of this post is how we implemented that inversion in Hono.
Two global guards, mounted before any route
The app is a single Hono instance with a /api/v1 base path. The load-bearing detail is the order in which middleware is registered relative to the routes:
// apps/web/app/api/v1/app.ts (excerpt)
export const app = new Hono<{ Variables: ApiVariables }>().basePath("/api/v1");
// Rate-limit runs BEFORE auth so that unauthenticated brute-force attempts are
// throttled without triggering DB auth queries. Defaults: 100 req / 60 s per IP
// (matches the client-side RateLimiter in apps/expo/services/apiClient.ts).
app.use("*", rateLimitMiddleware());
// Auth guard: every route is protected by default.
// Public paths are declared in middleware/auth.ts PUBLIC_PATHS allowlist.
app.use("*", authMiddleware);Both guards are registered with app.use("*", ...) before any sub-router is mounted. Hono runs wildcard middleware registered ahead of a route regardless of where that route is later attached, so every one of the sub-routers mounted below inherits both middlewares:
// apps/web/app/api/v1/app.ts (excerpt — 20 sub-routers, elided)
app.route("/account", accountRouter);
app.route("/tags", tagsRouter);
// ...
app.route("/diaries", diariesRouter);
app.route("/diary-entries", diaryEntriesRouter);
app.route("/notes", notesRouter);
app.route("/sync", syncRouter);
app.route("/ai/assist", aiAssistRouter);Crucially, the route files themselves add no auth. A handler in routes/tags.ts just reads the user the middleware already resolved:
// a route handler, simplified — no auth call, it trusts the global guard
const user = c.get("user");Adding a route is one app.route(...) line plus a sub-router that reads c.get("user"). You do nothing for auth. That is the entire point: the easy path is the secure path.
Why rate limiting runs before auth
Notice that rateLimitMiddleware() is registered before authMiddleware. That ordering is intentional, and the middleware documents its own reasoning:
// apps/web/app/api/v1/middleware/rate-limit.ts (excerpt)
/**
* Placed BEFORE authMiddleware in app.ts so that:
* - Unauthenticated brute-force attempts are throttled before auth DB queries run.
* - Auth errors and rate-limit errors are independent concerns.
*
* Exceeding the limit returns 429 with the unified error envelope.
*/If auth ran first, every rejected request would still pay for a session lookup — which is exactly the work an attacker wants to force by spraying invalid credentials. Throttling first means the cheap check protects the expensive one. Keeping the two guards independent also means an auth failure and a rate-limit failure never entangle: one returns 401, the other 429, and neither depends on the other's outcome.
An honest caveat: the default store is an in-memory sliding window, and the singleton lives per process. In a multi-instance serverless deployment that makes the limit per-instance rather than global — the code frames it as a throttle that mirrors the client's own limiter behind Cloudflare, not a hard global ceiling. The store is a pluggable interface, so swapping in Redis/KV later doesn't touch the middleware.
Public by allowlist, private by default
The auth middleware is short, and the shape of it is the whole security model. A request is allowed through unauthenticated only if its full path is a member of an explicit Set:
// apps/web/app/api/v1/middleware/auth.ts (excerpt)
// Explicit allowlist of paths that do not require authentication.
// Use the full path as returned by c.req.path (i.e. including /api/v1 prefix).
// Adding a path here is the only way to make a route public.
const PUBLIC_PATHS = new Set(["/api/v1/health", "/api/v1/maintenance", "/api/v1/app-updates"]);
export const authMiddleware: MiddlewareHandler<{ Variables: ApiVariables }> = async (c, next) => {
if (PUBLIC_PATHS.has(c.req.path)) {
return next();
}
const { user, error } = await authenticateApiRequest(c.req.raw as unknown as NextRequest);
if (error || !user) {
const body: ApiErrorBody = {
error: "Unauthorized",
code: "AUTH_FAILED",
message: "Authentication required",
};
return c.json(body, 401);
}
c.set("user", user);
await next();
};Two decisions are worth calling out:
- Exact match, not prefix.
PUBLIC_PATHS.has(c.req.path)tests full-path set membership including the/api/v1prefix. Opening/api/v1/healthcannot accidentally open/api/v1/health-records. (We make the opposite choice one layer up, in the page-level proxy, where public sections like/bloggenuinely want prefix matching — different boundary, different rule.) - The default branch always authenticates. Anything not on the list — a brand-new route, or a path that doesn't exist at all — falls through to
authenticateApiRequest. No user, no entry.
authenticateApiRequest is backed by Better Auth and resolves the session from request headers, transparently handling both web cookies and the mobile client's Authorization: Bearer token, so route code never has to care which platform is calling.
The same file layers in-handler backstops on top of the middleware — checkOwner returns 404 (not 403) on an ownership mismatch so the API doesn't leak whether a resource exists, and there are server-side guards that stop a guest session or an over-limit account from bypassing a client-only gate. The middleware answers "are you signed in?"; these answer "are you allowed to touch this?" Defense in depth, not a single wall.
One error envelope, defined once
Every rejection above returns the same shape. That shape isn't declared in the API — it's a Zod schema in a platform-agnostic package that both the Next.js server and the Expo client import:
// packages/api-contract/src/common.ts (excerpt)
export const ApiErrorSchema = z.object({
error: z.string(),
code: z.string(),
message: z.string(),
});
export const ApiErrorCode = {
AUTH_FAILED: "AUTH_FAILED",
NOT_FOUND: "NOT_FOUND",
VALIDATION_ERROR: "VALIDATION_ERROR",
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
INTERNAL_ERROR: "INTERNAL_ERROR",
// ...
} as const;On the server, the response body type is derived structurally from that schema:
// apps/web/app/api/v1/app.ts (excerpt)
// ApiErrorBody is structurally derived from the api-contract schema so that
// type drift between the server response shape and the Expo client type is a
// compile error rather than a runtime surprise.
export type ApiErrorBody = ApiError;Because api-contract is server-code-free, the mobile app can depend on it without dragging in the libSQL driver, and the two sides can never disagree about what an error looks like — if the server starts returning a differently-shaped body, TypeScript stops the build. The catch-all onError handler and the notFound handler both emit this envelope too, so a 500, a 404, a 401, a 429 and a 400 validation failure all speak the same language to the client's error handling.
The test is the guarantee, not the comment
None of this was the original design. The first version of the app was just new Hono().basePath("/api/v1") with no global guard, and auth was applied in a way that depended on the order routes were registered. A reviewer flagged it: the auth boundary was registration-order dependent, which meant a route mounted in the wrong place could be inadvertently public. The fix (commit dce05ce3) registered the global guard up front and moved to the allowlist model — making auth order-independent — and, importantly, added a test that encodes the invariant:
// apps/web/tests/api/v1-auth.test.ts (excerpt)
it("returns 401 AUTH_FAILED for unauthenticated requests to any unknown path", async () => {
// Regression: authMiddleware must run for every route regardless of registration order
authenticateApiRequest.mockResolvedValue({ user: null, error: new Error("Unauthorized") });
const res = await app.fetch(new Request("http://localhost/api/v1/__nonexistent__"));
expect(res.status).toBe(401);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("AUTH_FAILED");
});The __nonexistent__ path is the clever part. It asserts that auth runs for a route that doesn't exist — which is a proxy for the route that doesn't exist yet. A comment saying "protected by default" rots the moment someone refactors the middleware order; this test fails loudly. That's the difference between an intention and a guarantee.
Takeaways
- Make the secure state the default state. Global auth + a public allowlist means forgetting protects a route instead of exposing it.
- Order your middleware for cost and independence: throttle before you authenticate.
- Match public paths exactly at the API boundary; save prefix matching for genuine public sections.
- Define shared response shapes once, in a platform-neutral package, and let the type checker enforce agreement between server and client.
- Turn the invariant into a test — especially the "unknown route" case, because that's the one humans forget.
For how we choose which permission model sits behind that boundary — flags, RBAC, or ABAC — see Permission design for solo devs. For the request-level identity plumbing (how a Bearer token and a cookie resolve to the same user), see Auth for Next.js and Expo.
Try Storyie
Storyie is a diary and story app for web and mobile, built on the stack described in these posts. If you want to keep a private journal or publish your stories, give it a try.