parcelpump
Sign in

Display

DOCS / AUTHENTICATION

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:

  • farminggame calling api.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-server npm 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_KEYS env-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

  1. Admin browses to parcelpump.dev/admin/scraping.
  2. 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.
  3. Page renders; server actions execute.
  4. Server actions call parcelpump-api.ts which sends the request to api.parcelpump.io with X-Parcelpump-Key: ${PARCELPUMP_OPS_KEY}.
  5. The parcelpump API auth helper hits step 1 above (env map lookup), sees the ops key has admin capability, 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

  1. User browses to parcelpump.dev/sign-up.
  2. Clerk handles email + password (or magic link / OAuth).
  3. After verification, Clerk webhook (or first-render fallback) creates an accounts row keyed by clerk_user_id.
  4. User browses to parcelpump.dev/dashboard/keys.
  5. Clicks "Issue new key", types a name (e.g. farminggame-prod, personal-dev).
  6. 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).
  7. 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 channel
  • funder — flagged on accounts that have funded scrape work; affects refresh-cadence write rules
  • county-officer — for the county-trust registry; gated by .gov email 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_KEYS env 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:

  1. Visit http://localhost:3000/sign-in
  2. Enter your email, click "Send magic link"
  3. Email arrives within ~5s from auth@parcelpump.dev
  4. Click the link → land on /dashboard
  5. First login lazily creates public.accounts row via the jwt callback. Confirm:
    psql "$DATABASE_URL" -c \
      "SELECT id, contact_email, role FROM public.accounts WHERE contact_email='YOUR_EMAIL';"
    
  6. Bootstrap admin (per "Operational" above), sign out, sign back in.
  7. Confirm /admin/users opens; promote/demote a test user; verify the self-demote / last-admin guards reject the call.
  8. Visit /dashboard/keys, issue a key with the enqueue capability checkbox.
  9. Save the plaintext, then test the API:
    curl -H "X-Parcelpump-Key: <plaintext>" http://localhost:3030/v1/sources
    
  10. Revoke the key in the UI; the same curl should 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.