boxkite's auth and abuse-control surface
Most of the security writing on this blog points at the runtime — pods, dropped capabilities, network-dark isolation. This post looks the other way, at the control-plane that sits in front of that runtime and answers a narrower question before any pod exists: whois allowed to ask for one, and how hard can they lean on that question before they're throttled? The surface that answers it isn't a single gate. It's a dual-credential model, an admin tier layered on top, an OAuth 2.1 flow for MCP clients, a whole family of short-lived single-use tokens for the browser flows that can't safely hold a real credential, optional enterprise SSO/SCIM, and a named rate-limit bucket on every mutating route. The through-line, everywhere, is that no single check is doing all the work — and every piece is small enough to read.
Two credentials that never substitute for each other
The control-plane issues two kinds of credential and refuses to let them stand in for one another. A short-lived dashboard JWT (from /v1/auth/login) authenticates account management — creating and revoking API keys, reading your account. A long-lived API key (bxk_live_…) authenticates the resource surface — everything under /v1/sandboxes/*. The split is enforced, not conventional: the dependency that guards dashboard routes rejects anything that looks like an API key, and the one that guards sandbox routes rejects a JWT — both raising a specific wrong_credential_type error rather than a vague 401.
The discrimination is cheap and happens before any database hit. A single predicate, looks_like_api_key, checks for the bxk_live_prefix; only if the shape matches the route's expectation does the request proceed to the expensive part — a hash lookup for an API key, or a signature verification for a JWT. A caller who fat-fingers the wrong credential into the wrong route gets a precise, actionable error instead of a generic rejection, and the server hasn't spent a DB round-trip finding that out.
# The two credentials can never stand in for one another.
async def get_current_user(authorization, db): # dashboard routes
token = _extract_bearer_token(authorization)
if looks_like_api_key(token): # bxk_live_… here?
raise ApiError(401, "wrong_credential_type",
"This endpoint requires a user session token, not an API key")
payload = decode_access_token(token) # JWT only past this point
...
async def get_current_account_via_api_key(authorization, db): # sandbox routes
token = _extract_bearer_token(authorization)
if not looks_like_api_key(token): # a JWT here?
raise ApiError(401, "wrong_credential_type",
"This endpoint requires an API key, not a user session token")
key_row = await api_keys.get_active_by_hash(hash_secret(token)) # non-revoked only
...One request surface, two credential types
wrong_credential_type — the two are never interchangeable.There is exactly one deliberate, narrow exception, and it's documented as such: POST /v1/sandboxes also accepts a short-lived, single-use sandbox_createtoken minted from a logged-in dashboard session, so the dashboard's create-sandbox form doesn't have to paste a long-lived API key into browser JavaScript. Every other sandbox route stays API-key-only, and the widening is scoped to creation alone — it doesn't leak into exec, file, or lifecycle routes. API keys also carry a role — admin or member— and the more sensitive actions (initiating a live human takeover of a session, for instance) fail closed for anything that isn't explicitly admin: can_initiate_takeover returns False for member and for any unrecognized role value, rather than defaulting to permissive.
Two smaller wrinkles round out the credential model. First, an admin tier layers on top of the API-key check: /v1/admin/* routes additionally require Account.is_admin, and every admin call durably writes an AdminAccessLog row beforethe handler runs — cross-account visibility is sensitive new surface, and the accountability story is "every access is logged," not "trust the handler to log it." A non-admin key gets a 403, not a 404, because there's nothing to hide about whether the admin routes exist; what's protected is the cross-account data they return, not their names. Second, two browser-native streaming endpoints — /watch (EventSource) and /takeover(WebSocket) — can't set a custom Authorization header at all, so they accept the API key as an api_keyquery parameter instead. That's a knowingly more-exposed path (query strings land in access logs and browser history), so it's confined to exactly those two routes and nothing else is allowed to opt into it.
API keys: hashed at rest, revocable on the next request
An API key is an opaque, high-entropy random string (32 bytes of secrets.token_urlsafe entropy). Only its SHA-256 digest is ever persisted or logged; the raw value is shown to the caller exactly once, at creation time, and cannot be retrieved again. A short, non-secret prefixis stored alongside the hash purely for display — enough to visually identify a key in a "your keys" list, or to recognize one that's leaked into a log, without being a usable credential itself. Passwords, by contrast, go through argon2 (a memory-hard KDF) rather than a bare SHA-256 — the two are hashed differently on purpose, because a password must resist offline cracking of a low-entropy human secret, while an API key is already high-entropy and only needs a fast, constant-time lookup key.
The hash-only storage has a useful consequence for revocation. Because every authenticated request looks the key up by hash, and that lookup (get_active_by_hash) only returns non-revoked keys, revoking a key (DELETE /v1/api-keys/{id}) takes effect on the very next request that key makes — there's no cached-session window to wait out, because there is no session to cache. The same "check on every request" discipline is why a SCIM-deactivated account loses access immediately too, not just at its next login. Each successful lookup also stamps a last_used timestamp, so a stale or forgotten key is easy to spot and prune before it becomes a liability.
A short-lived token for every browser dead-end
The dashboard and the demo playground keep running into the same problem: a browser needs to do one privileged thing — open a takeover WebSocket, stream a preview URL, create a sandbox — but the only credential strong enough to authorize it is a long-lived API key, and a long-lived API key has no business being pasted into page JavaScript or hung off a WebSocket URL. The answer, repeated across the codebase, is a family of short-lived, single-use JWTs, each minted from an already-authenticated context and each scoped so tightly that leaking one buys an attacker almost nothing.
| Token type | Minted for | Bound to / guard |
|---|---|---|
sandbox_create | Creating a sandbox from a logged-in dashboard session | account_id; jti single-use |
sandbox_takeover | Live human takeover of a session (WS) | (account, session); jti; optional read_only |
sandbox_desktop | VNC / GUI computer-use stream (WS) | (account, session); jti single-use |
sandbox_preview | Proxying a preview URL to a running port | (session, port); jti, DB-revocable |
mcp_access | Authenticating an MCP client to the resource server | aud (RFC 8707); stateless, short TTL |
social_login_state / enterprise_sso_state | CSRF defense on a redirect-based login | nonce ⇄ HttpOnly cookie |
account_link_intent | Linking a provider identity onto an existing account | account_id + provider; short TTL |
oauth_login_session | The MCP consent screen's browser session | cookie scoped to /oauth/* |
What makes the family a family, rather than a pile of ad-hoc tokens, is one shared discipline. Every kind is signed with the same JWT_SECRET but carries a distinct type claim, and each decode_* helper checks that claim — so a preview token can never be accepted where a takeover token is expected, even though the signing key is identical. Just as important is what the decode helpers deliberately don't do: they validate signature, expiry, and type, and nothing else. Session binding (does payload["session_id"] match the session the request actually names?) and single-use consumption (has this jti been redeemed already?) are the caller's job, spelled out in each helper's docstring as "never trust the token alone." A valid signature only proves the token wasn't forged; it says nothing about whether it's being replayed or aimed at the right resource.
MCP clients: OAuth 2.1 with PKCE and audience-bound tokens
The hosted MCP endpoint is an OAuth 2.1 protected resource, so an MCP client (Claude, Cursor, and friends) doesn't use an API key at all — it goes through a standards-track authorization flow. The flow is self-describing: /.well-known/oauth-protected-resource advertises the resource, and /.well-known/oauth-authorization-server advertises the endpoints and, notably, a code_challenge_methods_supported list containing exactly ["S256"]. A client that doesn't hold an OAuth token yet is turned away with a 401 that points it at that metadata, so discovery is part of the protocol rather than something a human wires up by hand.
MCP OAuth 2.1: discovery, consent, audience-bound token
/.well-known metadata, registers dynamically (RFC 7591), completes an authorize step that requires PKCE S256 and a single-use 60-second code, then exchanges it for an audience-bound access JWT plus a DB-backed refresh token.Several details make that flow defensible rather than decorative. It requires PKCE, and specifically the S256 challenge method — the plain method is never listed in the metadata and never accepted anywhere in the codebase — so an intercepted authorization code is useless without the matching verifier. Clients register dynamically (RFC 7591 dynamic client registration), and that registration endpoint gets its own conservative rate-limit bucket rather than inheriting a shared one. The authorization code itself is opaque, high-entropy, single-use, and expires in 60 seconds; the redirect_uripresented at every step must exactly match one the client registered, so a code can't be redirected somewhere the client never declared. The consent screen runs on its own oauth_login_session cookie — scoped to /oauth/*, proving only "this browser just logged in to approve an MCP client," carrying no API privilege even though it's signed with the same secret as the dashboard token.
The access tokens the flow mints carry an audienceclaim (an RFC 8707 resource indicator): a token minted for the MCP resource server can't be replayed against a different endpoint that merely happens to trust the same signing key, because decode_mcp_access_token makes the caller pass the audience it expects and rejects any mismatch. The access token is a stateless, short-lived JWT (15-minute default TTL, cheap to verify on the hot path); the refresh token is DB-backed and individually revocable. One pragmatic nuance worth stating plainly: on the MCP surface the auth middleware tries to decode a Bearer as an MCP access token first and, failing that, falls back to the ordinary API-key hash lookup — so the MCP endpoint accepts eitheran OAuth token or a normal API key. That's a deliberate convenience for script-style clients, not an accident, but it's the one place the otherwise-strict credential separation softens by design.
Social login: a signed state isn't enough — bind it to the browser
GitHub and Google sign-in round-trip a statevalue through the provider's redirect. boxkite mints that stateas a signed, short-lived JWT, which proves the server issued it — an attacker can't forge one without the signing secret, and its exp bounds how long a captured value could ever be replayed, all without a server-side state table. But a signed state, on its own, says nothing about which browser it was issued to, and that gap is a real attack: an attacker completes their own login round-trip, captures a valid (code, state)pair, and hands it to a victim as a link. If the victim's browser follows it, the callback logs the victim into the attacker's account — classic login-CSRF / session fixation (RFC 6749 §10.12).
The fix is a state-nonce browser binding. When the flow starts, the server embeds a random nonce in the signed state and sets that same nonce in a short-lived HttpOnly cookie. On callback, it rejects the request unless the nonce inside the state matches the cookie the same browser presents. An attacker can supply a valid (code, state)pair, but not the victim's cookie — so the pair is inert in anyone else's browser. A small but deliberate detail: the cookie's max_age is sized from the very same TTL constant as the state token's exp(600 seconds), so the cookie can never outlive the token it's meant to validate, and a stale cookie can't linger to validate a fresh attack.
The same machinery does double duty for account linking. When an already-logged-in user asks to attach a GitHub or Google identity to their existing account, the dashboard mints a narrow account_link_intent token and passes it as a ?link_token= parameter on the top-level navigation — because a browser redirect can't carry an Authorization header, and the alternative (putting the real dashboard token in the URL) would leak general API access into browser history. Even if the link-intent value leaks, it can complete exactly one link action for one account within its short TTL, and nothing more.
Enterprise SSO and SCIM, off by default
For organizations that need them, enterprise SSO and SCIM directory sync are available — and both ship disabled by default, behind their own independent feature flags, so a small deployment never inherits an identity-broker integration it didn't ask for. (SCIM's flag is deliberately separate from SSO's: you can enable directory-driven deactivation without adopting SSO login, or vice versa.) SSO uses the same signed-state-plus-nonce-cookie browser binding the social-login flow does, but kept as its owntoken type — so even though both are signed with the same key, an SSO state can't be replayed into the social-login callback or the reverse.
SCIM's deactivation is wired into the same per-request account check the API-key path uses. When a directory deactivates a user, the account's scim_deactivated_at is stamped, and _reject_if_scim_deactivated — called every time any credential (API key or dashboard JWT) is resolved back to an account — turns the very next authenticated request into a 401 account_deactivated. Crucially, that check runs at credential resolution time, not just at issuance time: a credential minted long before the deactivation still dies on its next use, because the enforcement point is the resolution path every request already goes through, not the login path a deactivated user will never take again.
A rate-limit bucket on every mutating route
The last layer is volume control, and the design choice worth calling out is that there isn't one global limiter — every mutating route gets its own named bucket with its own conservative limit, so hammering login can't exhaust the budget for, say, image builds, and vice versa. The defaults are deliberately asymmetric and reflect each surface's risk: auth endpoints default to 10 requests/minute, sandbox exec/file ops to 120, lifecycle (create/destroy) to 20, snapshots and secrets to 10 each, preview proxying to 300, and the password-reset and email-verification paths to 5 apiece — each tuned to what a legitimate client actually needs versus what a brute-force or enumeration attempt would demand.
The keyingmatters as much as the limits. Auth endpoints are keyed by source IP, because they run before you're authenticated — there's no account id to key on yet. Already-authenticated routes are keyed by account id instead, so a shared or NATed IP doesn't let one tenant's traffic penalize unrelated callers behind the same egress address. The MCP tool surface is bucketed too — the tools an MCP client can call map onto the same sandbox/image/volume buckets the REST routes use, so there's no unmetered side door into the resource surface.
| Surface | Per-route buckets |
|---|---|
| Auth | signup, login, refresh, logout, password-reset request/confirm, email-verify/resend |
| Sandboxes | sandbox_ops (exec/file/process), sandbox_lifecycle (create/destroy), sandbox_preview |
| Resources | image_build_ops, volume_ops, secret_ops, webhook_ops, snapshot_ops, mcp_connection_ops |
| OAuth / MCP | oauth_dcr (dynamic client registration), oauth_authorize_login, and the MCP tool surface reusing the sandbox/image/ volume buckets above |
| Enterprise | scim_webhook and the SSO/SCIM flows (off by default) |
Two backends sit behind that single enforce_rate_limitentry point, and callers don't need to know which is active. The default is an in-memory sliding window: a per-process OrderedDict mapping each bucket key to a dequeof request timestamps, pruned to the last 60 seconds on every check and LRU-capped at 10,000 tracked keys so a flood of distinct keys can't grow it without bound. It's exact, correct, and dependency-free — for a singlecontrol-plane replica. The catch is that its state isn't shared: run two replicas and each enforces its own independent copy, so the cluster-wide effective limit becomes limit × replicas.
Any deployment running more than one replica must therefore switch to the Postgres-backed shared counter (BOXKITE_RATE_LIMIT_BACKEND=postgres), which reuses the service's own database so there's no Redis to run. It's a fixed-window counter keyed on floor(now / window), and it does the increment as an UPDATE … RETURNING; on the first hit of a brand-new window there's no row yet, so it falls back to an INSERT and, if two replicas race that insert, recovers from the resulting IntegrityError by re-running the increment rather than failing the request. Either backend surfaces a 429 with X-RateLimit-* and Retry-After headers so clients can back off intelligently instead of parsing error bodies. The one honest cost of the fixed window is that a caller straddling a window boundary can briefly see up to ~2× the configured limit — the standard, accepted tradeoff of fixed-window counting.
Verifying the surface yourself
None of this is meant to be taken on faith — the surface is small enough to poke at directly, and the checks below are the ones worth running against any deployment you operate:
| Check | Expected result |
|---|---|
| Wrong credential type | A dashboard JWT sent to /v1/sandboxes/* (or an API key to /v1/api-keys) returns 401 wrong_credential_type |
| Revocation window | Create a key, use it, DELETE it, reuse it — the next request fails invalid_api_key with no grace period |
| PKCE method | /.well-known/oauth-authorization-server lists code_challenge_methods_supported: ["S256"] — never plain |
| Rate limiting | Exceed a bucket's per-minute limit and get a 429 carrying X-RateLimit-* and Retry-After |
| Multi-replica | If you run more than one replica, confirm BOXKITE_RATE_LIMIT_BACKEND=postgres — otherwise limits multiply by replica count |
What this is, and what it isn't
The honest framing is defense in depth across the request surface: distinct credentials for distinct jobs, an admin tier that logs before it acts, a standards-track flow for MCP clients, a family of tightly-scoped single-use tokens for the browser flows that can't hold a real credential, browser-bound state for redirect logins, and a throttle on every mutating path. None of it is a substitute for the runtime isolation the rest of this blog covers — it's the layer that gates access to that runtime, not a replacement for what happens once code is running inside a pod.