Authentication model
How parcelpump distinguishes operators from end users, and what kind of key/credential each one carries.
Two key types — DO NOT conflate
There are two completely separate identity surfaces. They share an HTTP
header (X-Parcelpump-Key) but live in different stores, are issued
differently, and grant different things.
1. End-user / consumer / app keys
The keys every external user gets. Issued via the /dashboard flow on
parcelpump.dev to authenticated humans signed up through Clerk. Used by:
farminggamecallingapi.parcelpump.io/v1/parcels/...- A third-party real-estate research startup
- A future ag-research consultancy
- A user who wants to query parcelpump from a Jupyter notebook
- The
@parcelpump/mcp-servernpm package, configured with the user's own key
Stored in the api_keys Postgres table. Hashed at rest. Capabilities,
account ownership, last-used-at, revoked-at all tracked per row.
Revocable individually. Usage logged per-request.
This is the load-bearing identity surface. It scales to thousands of users.
2. Service-account keys for the operator UI (PARCELPUMP_OPS_KEY)
A small set of keys (typically 1, plus a break-glass spare) that the
parcelpump.dev operator UI uses to call the parcelpump REST API on
behalf of an authenticated admin human. The admin human authenticates
through Clerk like any other user; the server actions on /admin/*
then make HTTP calls to api.parcelpump.io using PARCELPUMP_OPS_KEY.
The ops key is never exposed to the browser. It lives in:
web/.env.local(dev)- Lambda env via CDK in prod
- The
PARCELPUMP_API_KEYSenv-var map on the parcelpump API (which treats it like any other admin-capability key for auth purposes)
Why not just give every admin a personal end-user key with admin capability? Because the human is identified by Clerk, not by an API key. Coupling the per-human identity to a long-lived shared-secret API key creates rotation pain when an admin leaves the team. The ops key is a service-account credential — bound to the UI's role, not to a person — and rotates on its own schedule.
Quick reference
| Concept | Where it lives | Issued how | Who uses it |
|---|---|---|---|
| End-user API key | api_keys table |
/dashboard flow, Clerk-authenticated |
External consumers, MCP users, scripts |
| Service-account ops key | PARCELPUMP_API_KEYS env map (1-2 entries) |
Generated by ops, pinned in infra/.env.deploy + Lambda env |
Operator UI server actions on /admin/* |
| Break-glass admin key | Same env map | Generated by ops, kept off-machine in a password vault | Manual recovery only |
How the parcelpump API auth helper resolves a request
Request arrives with X-Parcelpump-Key: <key>
↓
1. Lookup `<key>` in PARCELPUMP_API_KEYS env map.
- Hit → use the env-map capabilities. (Used by ops key + break-glass.)
- Miss → continue.
↓
2. Hash `<key>` with the same algorithm used at issue time, look up
`api_keys.hashed_key`. Filter on `revoked_at IS NULL`.
- Hit → use the row's capabilities; bump `last_used_at` async.
- Miss → 401.
↓
3. Capability check (e.g. `enqueue` for write endpoints).
- Pass → handle.
- Fail → 403.
The env-map path is checked first because it's tiny and warm. It will always have at most 2-3 entries. The DB lookup happens only on a miss, so 100% of normal-user traffic hits the DB once per request and is cheap.
End-user keys never appear in the env-var map. Ops keys never appear in the DB.
How the admin UI authenticates the human + the API call
- Admin browses to
parcelpump.dev/admin/scraping. - Clerk middleware (Next.js) checks the session cookie.
- No session → redirect to
/sign-in. - Session exists → check
clerk.user.publicMetadata.role === "admin". - Not admin → 403.
- No session → redirect to
- Page renders; server actions execute.
- Server actions call
parcelpump-api.tswhich sends the request toapi.parcelpump.iowithX-Parcelpump-Key: ${PARCELPUMP_OPS_KEY}. - The parcelpump API auth helper hits step 1 above (env map lookup),
sees the ops key has
admincapability, lets the request through.
The browser never sees the ops key. The Clerk session cookie is the human's credential; the ops key is the UI's credential.
How an end user signs up + issues a key
- User browses to
parcelpump.dev/sign-up. - Clerk handles email + password (or magic link / OAuth).
- After verification, Clerk webhook (or first-render fallback) creates
an
accountsrow keyed byclerk_user_id. - User browses to
parcelpump.dev/dashboard/keys. - Clicks "Issue new key", types a name (e.g.
farminggame-prod,personal-dev). - Server action:
- Generates 32 random bytes → hex key
pk_xxx.... - Hashes with SHA-256 (or argon2id; decision below).
- Inserts into
api_keys(id, account_id, name, hashed_key, capabilities, created_at). - Returns the plaintext key once (UI shows it; user must save it).
- Generates 32 random bytes → hex key
- User pastes the key into their app's env / Claude Code MCP config / Jupyter notebook.
Keys are displayed once at issue time and never again. Lost-key recovery = revoke + re-issue.
Hash algorithm decision
SHA-256 for v0. Reasons:
- API keys are high-entropy random bytes (32 bytes ≈ 256 bits). Brute-force isn't a realistic threat the way it is for human-chosen passwords.
- SHA-256 is fast enough that the per-request DB lookup doesn't move the latency needle.
- argon2id is the right answer for low-entropy human passwords; using it here would be theater.
If/when we ever accept human-chosen API keys (please no), upgrade to argon2id at that time.
Capabilities
Currently shipped:
| Capability | Granted to | Allows |
|---|---|---|
| (none / read) | Default for all keys | All read endpoints — search, parcels, sources, etc. |
enqueue |
Paid-plan keys + ops key | POST /scrape-jobs, POST /scrape-jobs/enqueue-ag-county |
admin |
Ops key + break-glass | All PATCH/DELETE admin endpoints, POST /admin/concurrency/run-tuner, etc. |
Future caps (placeholders, not yet enforced):
bulk-export— issues signed URLs for the bulk export channelfunder— flagged on accounts that have funded scrape work; affects refresh-cadence write rulescounty-officer— for the county-trust registry; gated by.govemail verification
Operational
Bootstrap the first admin: there is no admin until SQL says so. After you sign up via
/sign-in(email magic-link), run:set -a && . ./.env.local && set +a psql "$DATABASE_URL" -c \ "UPDATE public.accounts SET role='admin' WHERE contact_email='YOUR_EMAIL_HERE';"Sign out and back in so the JWT picks up the new role. From then on, promote/demote any user via
/admin/users— the system enforces a minimum of one admin so you can't accidentally lock everyone out.Ops key rotation: generate new key, add to
PARCELPUMP_API_KEYSenv map, deploy, then remove old key in the next deploy. ~5 min.End-user key rotation: user revokes old, issues new in
/dashboard. UI nudges them to do this every 90 days; not enforced.Compromised end-user key: user (or admin) revokes the row. Capability check fails immediately on next request.
Compromised ops key: rotate the env map. The shared secret problem is exactly why end-user keys don't share infrastructure with ops keys.
Verification
Auth chain smoke (automated)
npm run smoke:phase5
Exercises the full parcelpump REST API auth chain — stage-1 env-map +
stage-2 api_keys table lookup, capability gating, last_used_at
bumping, revocation. Inserts a temporary fixture account + key, runs 8
assertions, cleans up. Used as the regression check whenever the auth
resolver in src/api/server.ts changes.
Manual end-to-end (browser)
The Auth.js sign-in flow can't be exercised by the smoke (real email delivery + click). Walkthrough:
- Visit
http://localhost:3000/sign-in - Enter your email, click "Send magic link"
- Email arrives within ~5s from
auth@parcelpump.dev - Click the link → land on
/dashboard - First login lazily creates
public.accountsrow via the jwt callback. Confirm:psql "$DATABASE_URL" -c \ "SELECT id, contact_email, role FROM public.accounts WHERE contact_email='YOUR_EMAIL';" - Bootstrap admin (per "Operational" above), sign out, sign back in.
- Confirm
/admin/usersopens; promote/demote a test user; verify the self-demote / last-admin guards reject the call. - Visit
/dashboard/keys, issue a key with theenqueuecapability checkbox. - Save the plaintext, then test the API:
curl -H "X-Parcelpump-Key: <plaintext>" http://localhost:3030/v1/sources - Revoke the key in the UI; the same
curlshould now 401.
Boundaries to remember
- End users never see the ops key — never copy it into a user's config, never let it touch a browser, never ship it in any client-side code.
- The env-var keys map stays small — it's the bootstrap layer, not
the user database. End-user keys go in
api_keys. - The MCP server uses an end-user key — when a user installs
@parcelpump/mcp-server, they paste in their own key from the dashboard. The MCP server itself has no parcelpump credential.