Skip to content

e2a login accumulates API keys per invocation; align with per-device behavior #44

Description

@jiashuoz

Summary

Every successful e2a login mints a new API key via store.CreateAPIKey(ctx, userID, "CLI login") regardless of whether the user already has prior keys. There's no dedup, no replacement, no expiration. After running login a few times, a user has multiple keys all literally named "CLI login".

Sample outcome after three e2a login runs from different machines (or the same machine after a config wipe):

e2a_aaa…  CLI login   (active)
e2a_bbb…  CLI login   (active)
e2a_ccc…  CLI login   (active)

Concrete pain

  • Indistinguishable in the dashboard / future keys list. When a user wants to revoke "the key on the laptop I lost," they can't tell which row that is.
  • Stale keys remain valid forever. No expires_at field in the schema; revocation requires explicit user action. A key from a stolen device or a past colleague's machine is live until manually revoked.
  • Sprawl over time. Power users who re-clone configs or work across machines accumulate dozens of identically-named entries.

Surfaced during fresh-user e2e testing for the launch.

How comparable tools handle it

gcloud uses OAuth refresh tokens, not API keys. gcloud auth login <account> updates the entry in place — re-login doesn't accumulate credentials. Different model end-to-end, but the user-facing behavior is "one credential per account, replaced on re-auth."

gh (GitHub CLI) issues an access token via OAuth. Re-running gh auth login invalidates the prior token on the same scope; tokens are auto-named gh-cli for <hostname>. User-facing behavior is "one credential per device, with a meaningful name."

stripe CLI issues a device-scoped key, named after the host.

Proposed paths

Path A — Revoke-on-login (smallest change)

On e2a login:

  1. Read existing config; if api_key is set, capture its prefix (or call a whoami endpoint to get its ID)
  2. After OAuth completes and the new key is minted, revoke the old one
  3. Save the new key to ~/.e2a/config.json

Result: at most one CLI key per machine that's been using e2a login. Server-side change minimal — uses the existing key-revocation endpoint.

Path B — Hostname-aware names + per-name dedup (recommended)

On e2a login:

  1. CLI passes os.hostname() as the key name hint: "CLI login on joshzhang-MBP-2026" instead of the generic "CLI login"
  2. Server checks for existing keys with the same (user_id, name) pair and revokes them before minting the new one
  3. Each machine ends up with exactly one CLI key, distinctively named in the dashboard

Result: dashboard's "API keys" view becomes meaningful. User can identify and revoke a specific machine without guessing.

Path C — OAuth refresh-token model

Drop the long-lived API key for the CLI entirely. Issue an OAuth-style access token (~1hr) plus a refresh token. SDKs and CLI handle 401 → refresh → retry.

Better security model. Significant refactor: every SDK auth flow changes, all API consumers need refresh logic, the API-key-in-Authorization-header pattern becomes a parallel auth path.

My take

Path B. It's the right user-mental-model fit ("one device = one key, with a name I recognize") and it's well under a day of work:

  • CLI: pass os.hostname() in the key-creation request
  • API: when (user_id, name) collides on create, auto-revoke prior key
  • No SDK changes, no breaking auth changes for existing users

Path A is simpler but leaves the dashboard cluttered with identically-named keys. Path C is the architecturally right answer but costs more than its payback for an OSS launch.

Belt-and-suspenders worth considering on top of B: add a last_used_at column (already exists per migrations/001_init.sql) and run a periodic worker that revokes keys with last_used_at < now() - 90 days. Bounded blast radius for forgotten keys without bothering active users.

Priority

Not blocking launch. Should land before the project sees enough adoption that users start having multiple devices and forget which key is which — meaning soon-after-launch, not immediately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions