It's a Plan: Persistent Account Access via Passkey Enrolment with an API Key

A holder of a personal API key enrols a passkey on the owner's account and keeps signing in after a password reset and the revocation of every key.

Advisory ID: TP-2026-124
Product: It's a Plan (self-hosted project management and issue tracking with AI agents)
Vulnerability type: Improper authentication (CWE-287)
CVE: not requested
CVSS 3.1: 8.1 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
Affected versions: < 1.7.0
Vendor advisory: GHSA-54vc-ww62-5mhj
Fixed in: 1.7.0
Reported: 11 October 2026

Summary

It's a Plan is a self-hosted project management tool whose accounts are reached with a password, a passkey or a personal API key. A personal API key resolves to its owner's session, and the passkey endpoints accept it, so a request with x-api-key and no session cookie enrols a new passkey on the account. The same hooks.before block that refuses /api-key/create with x-api-key has no such clause for /passkey/*. The freshness check on the passkey endpoints has no effect, because the plugin builds a key's session with the current timestamp on every request. A holder of a leaked API key therefore keeps signing in as the account owner after a password reset and the revocation of every key.

Root cause

The passkey plugin is registered below a comment stating that a passkey is added to an already signed-in account (packages/auth/src/index.ts:618). The hooks.before block refuses /api-key/create when the request carries x-api-key and adds no clause for /passkey/* (packages/auth/src/index.ts:526). The rule this block is meant to enforce is stated in packages/auth/AGENTS.md:208: "The expiry only holds if a key cannot renew itself". @better-auth/api-key 1.6.27 builds the key's session object with createdAt: new Date() on every request (dist/index.mjs:2437), so freshSessionMiddleware on the registration endpoints never sees an old session. The enrolled passkey is a separate row with no expiry column and is unaffected by revokeSessionsOnPasswordReset and api-key/delete.

Proof of Concept

POST /api/auth/api-key/create                   x-api-key: <key>
-> 403 {"message":"An API key cannot create another API key. Sign in to create one."}

GET  /api/auth/passkey/generate-register-options  x-api-key: <key>
-> 200 {"challenge":"…","rp":{"name":"It's a Plan", …}}
POST /api/auth/passkey/verify-registration        x-api-key: <key> + ceremony cookie
-> 200 {"name":"attacker-passkey", …,"userId":"<victim-id>"}

Victim: password reset + deletion of every API key
-> passkey sign-in still returns a session of the victim

The API key counts as a full, always fresh session on the passkey endpoints, and the resulting passkey is not tied to the key. From the passkey session the attacker then creates a new API key with a fresh lifetime, which the key itself may not do.

Impact

  • A leaked personal API key converts into a login credential that the documented recovery does not remove.
  • The enrolled passkey has no expiry, while the key it came from expires after at most 365 days.
  • A session opened through the passkey creates new API keys with a fresh lifetime.
  • No email is sent for the enrolment; the passkey only appears under Account → Security with a name the attacker chose.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications tested by the same specialists, with a penetration test from turingpoint.