Authentication, Rate Limiting, and Shared Middleware
Prerequisites
- ›Articles 1-2
- ›Basic understanding of OAuth 2.0
- ›Familiarity with HTTP middleware patterns
Authentication, Rate Limiting, and Shared Middleware
The store manages data. The middleware stack controls who gets access and how that access behaves. In Articles 1 and 2, we saw the middleware listed in createServer() — now we'll understand why each layer exists and how it creates production-fidelity behavior. The auth middleware alone handles three distinct authentication paths, and the rate limiter is functional enough to test your application's retry logic.
The Middleware Stack Order
As we saw in Article 1, createServer() wires middleware in a specific order. Let's look at the full sequence with the rationale for each position:
packages/@emulators/core/src/server.ts#L25-L106
flowchart TD
REQ[Request] --> FONTS{"Font route?"}
FONTS -->|Yes| FONT_RES[Serve static font]
FONTS -->|No| ERR[onError handler registered]
ERR --> CORS[CORS: allow all origins]
CORS --> ERR_CTX[Error context: set docsUrl]
ERR_CTX --> AUTH[Auth: resolve identity]
AUTH --> RATE{Rate limit check}
RATE -->|Exceeded| R403[403 Rate Limited]
RATE -->|OK| PLUGIN[Plugin routes]
PLUGIN --> FOUND{Route matched?}
FOUND -->|Yes| RES[Response]
FOUND -->|No| R404[404 Not Found]
The ordering matters:
-
Fonts before everything — Static assets shouldn't be auth-gated or rate-limited. The font middleware short-circuits for
/_emulate/fonts/*paths. -
Error handler before CORS — If CORS middleware itself throws (it won't, but defensive), the error handler catches it.
-
CORS before auth — Preflight
OPTIONSrequests don't carry auth headers. CORS must respond before auth rejects them. -
Auth before rate limiting — Rate limiting is per-token, so we need to know who's making the request first.
-
Plugin routes before 404 — Anything the plugin doesn't match falls through to the catch-all.
Auth Resolution: Three Paths to Identity
The auth middleware handles three distinct authentication patterns in a single function:
packages/@emulators/core/src/middleware/auth.ts#L70-L112
flowchart TD
A[Authorization header present?] -->|No| Z[Continue as anonymous]
A -->|Yes| B[Extract token]
B --> C{"Token starts with 'eyJ'?"}
C -->|Yes| D{AppKeyResolver available?}
D -->|Yes| E[Decode JWT payload]
E --> F[Extract appId from 'iss' claim]
F --> G[Look up app's private key]
G --> H[Verify JWT with jose]
H -->|Valid| I[Set authApp context]
H -->|Invalid| Z
D -->|No| Z
C -->|No| J[Look up token in TokenMap]
J --> K{Found?}
K -->|Yes| L[Set authUser context]
K -->|No| M{Fallback user configured?}
M -->|Yes| N[Use fallback identity]
M -->|No| Z
N --> L
Path 1: JWT for GitHub Apps. When a token starts with eyJ (base64 JSON), it's treated as a JWT. The middleware decodes the payload to extract the iss (issuer) claim — which is the GitHub App ID — then looks up the app's private key through the AppKeyResolver callback, and verifies the signature using the jose library. On success, the Hono context gets an authApp with the app's ID, slug, and name.
Path 2: Bearer token lookup. Non-JWT tokens are looked up in the TokenMap — a Map<string, AuthUser> populated from the seed config or the defaults. This is the most common path in tests: Authorization: Bearer test_token_admin.
Path 3: Fallback user. If the token isn't in the map and a fallback user is configured, any non-empty token maps to that fallback identity. This is the "just works" path for local development — you don't need to know the exact token string, any value will authenticate as the default user.
Tip: The fallback user comes from
ServiceEntry.defaultFallback()in the registry. GitHub defaults to the first user's login, Google defaults to the first email. Check the registry if you're confused about which identity your requests resolve to.
The middleware also exports requireAuth() and requireAppAuth() middleware factories for routes that need authenticated access:
packages/@emulators/core/src/middleware/auth.ts#L114-L144
These are used selectively — public endpoints like /repos/:owner/:repo for public repos don't require auth, but /user/repos does.
Rate Limiting: Functional, Not Decorative
Most mocking solutions skip rate limiting entirely. Emulate implements it for real, because your application code should handle 403 rate-limit responses gracefully — and you can't test that without a rate limiter:
packages/@emulators/core/src/server.ts#L53-L91
const rateLimitCounters = new Map<string, { remaining: number; resetAt: number }>();
app.use("*", async (c, next) => {
const token = c.get("authToken") ?? "__anonymous__";
const now = Math.floor(Date.now() / 1000);
let counter = rateLimitCounters.get(token);
if (!counter || counter.resetAt <= now) {
counter = { remaining: 5000, resetAt: now + 3600 };
rateLimitCounters.set(token, counter);
}
counter.remaining = Math.max(0, counter.remaining - 1);
c.header("X-RateLimit-Limit", "5000");
c.header("X-RateLimit-Remaining", String(counter.remaining));
c.header("X-RateLimit-Reset", String(counter.resetAt));
c.header("X-RateLimit-Resource", "core");
if (counter.remaining === 0) {
return c.json({ message: "API rate limit exceeded", ... }, 403);
}
await next();
});
sequenceDiagram
participant C as Client
participant RL as Rate Limiter
participant R as Route Handler
C->>RL: Request (token: "test_token")
RL->>RL: Lookup counter for token
RL->>RL: Decrement remaining (4999)
RL->>R: Forward request
R-->>C: Response + X-RateLimit-Remaining: 4999
Note over C,R: After 5000 requests in the same hour...
C->>RL: Request #5001
RL->>RL: remaining = 0
RL-->>C: 403 "API rate limit exceeded"
The implementation tracks counters per token (or __anonymous__ for unauthenticated requests) with a 5000/hour budget. Windows reset hourly. A pruning pass cleans up expired counters every hour to prevent memory leaks.
The response headers match GitHub's real rate limit headers exactly: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and X-RateLimit-Resource. If your application reads these headers to implement backoff logic, the emulator exercises that code path.
Error Handling with ApiError
The ApiError class provides structured error responses that match real API error formats:
packages/@emulators/core/src/middleware/error-handler.ts#L50-L59
export class ApiError extends Error {
constructor(
public status: number,
message: string,
public errors?: Array<{ resource: string; field: string; code: string }>,
) {
super(message);
}
}
Helper factories create common errors:
export function notFound(resource?: string): ApiError { ... }
export function validationError(message: string, errors?: ApiError["errors"]): ApiError { ... }
export function unauthorized(): ApiError { ... }
export function forbidden(): ApiError { ... }
The createApiErrorHandler() function registered via app.onError() catches any thrown ApiError and formats the response:
packages/@emulators/core/src/middleware/error-handler.ts#L21-L36
Every error response includes { message, documentation_url } — matching GitHub's error format. The documentation_url defaults to https://emulate.dev/{service}, providing a link to the emulator's docs.
There's also a parseJsonBody() helper that returns a 400 with "Problems parsing JSON" if the request body isn't valid JSON — matching GitHub's exact error message for malformed payloads.
Pagination: Page-Based and Cursor-Based
Real APIs paginate. Emulate supports two styles used across different services.
GitHub-style: Link header pagination uses ?page= and ?per_page= query parameters:
packages/@emulators/core/src/middleware/pagination.ts#L1-L38
The setLinkHeader() function generates RFC 5988 Link headers with next, last, first, and prev relations — exactly as GitHub does. The per_page parameter is capped at 100 and defaults to 30.
Vercel-style: cursor pagination uses ?since=, ?until=, and ?limit= parameters:
packages/@emulators/vercel/src/helpers.ts#L61-L100
| Feature | GitHub Style | Vercel Style |
|---|---|---|
| Parameters | page, per_page |
since, until, limit |
| Navigation | Page numbers | Timestamps |
| Response | Link header |
pagination object in body |
| Default page size | 30 | 20 |
| Max page size | 100 | 100 |
| Cursor stability | Unstable (inserts shift pages) | Stable (timestamp-based) |
Per-Service Permission Models
Beyond the shared auth middleware, each service implements its own permission logic. The GitHub emulator has the most sophisticated model:
packages/@emulators/github/src/route-helpers.ts#L1-L79
flowchart TD
A["assertRepoRead(gh, authUser, repo)"] --> B{Repo public?}
B -->|Yes| C[✓ Access granted]
B -->|No| D{Auth user exists?}
D -->|No| E[✗ 401 Unauthorized]
D -->|Yes| F{User is owner?}
F -->|Yes| C
F -->|No| G{Org member?}
G -->|Yes| C
G -->|No| H{Collaborator?}
H -->|Yes| C
H -->|No| I[✗ 403 Forbidden]
The permission hierarchy is: owner > org member > collaborator > public access. The hasRepoAdmin() function additionally checks for admin or maintain collaborator permissions. These assertions are used in route handlers — assertRepoRead() for read endpoints, assertRepoWrite() for mutations, assertRepoAdmin() for settings changes.
Vercel uses a different model based on team scope resolution:
packages/@emulators/vercel/src/helpers.ts#L28-L51
export function resolveTeamScope(c: Context, vs: VercelStore) {
const teamId = c.req.query("teamId");
const slug = c.req.query("slug");
if (teamId) { /* look up team by ID */ }
if (slug) { /* look up team by slug */ }
// Fall back to authenticated user's personal account
}
Vercel routes use ?teamId= or ?slug= query parameters to scope requests to a team. If neither is provided, the request operates on the authenticated user's personal account. This matches Vercel's real API behavior where every request is implicitly scoped.
Shared OAuth Utilities
OAuth flows appear in six different emulators (GitHub, Google, Apple, Microsoft, Okta, Clerk). The shared utilities in oauth-helpers.ts prevent each from reinventing common patterns:
packages/@emulators/core/src/oauth-helpers.ts#L1-L38
Three key utilities:
-
normalizeUri()— Strips trailing slashes and query strings for redirect URI comparison. Handles both valid URLs and edge-case strings. -
matchesRedirectUri()— Checks if an incoming redirect URI matches any registered URI after normalization. -
constantTimeSecretEqual()— Wraps Node'stimingSafeEqualfor comparing client secrets without timing side-channels. Even in a local emulator, this is good practice — it exercises the same comparison logic your production code should use.
Tip: If your OAuth integration isn't working with the emulator, check redirect URI normalization first. Trailing slashes and query parameters are the most common mismatch.
What's Next
Now that we understand how the core infrastructure works — store, middleware, and auth — we're ready to see it all come together in a real emulator. In the next article, we'll walk through the GitHub emulator end-to-end: from 519 lines of entity definitions through 28 collections, two-phase seeding, route handler patterns, and a search query parser that handles is:pr author:octocat language:TypeScript.