Severity: CRITICAL — token-forgeability when APP_KEY unset, passkey replay attacks, password-reset confusion, plaintext OAuth secrets, weak crypto defaults.
Findings
| ID |
File:line |
Issue |
| A-1 |
auth/src/email-verification.ts:32, 42 |
Hardcoded 'stacks-default-key' fallback for HMAC when config.app.key is unset. Both generateVerificationToken and verifyToken use this literal. Anyone reading the source can forge email-verification tokens against installs that haven't set APP_KEY. |
| A-4 |
defaults/app/Actions/Auth/VerifyAuthenticationAction.ts:48-57 |
Passkey: action calls verifyAuthenticationResponse(..., userPasskey.counter) but never persists the new counter back. WebAuthn anti-cloning defeated — a stolen authenticator can be reused indefinitely. Also accepts challenge from the request body rather than a server-stored nonce → challenge replay attacks. |
| A-5 |
auth/src/password/reset.ts:62-75, 101-130, 138-200 + actions/src/auth/setup.ts:179-205 |
createResetToken() inserts a new row but never clears prior unused tokens for the same email. Verifier then selectFrom('password_resets').where('email', '=', email).executeTakeFirst() is DB-order-dependent. The password_resets schema has no expires_at column and no unique key on email/token. Single-use enforcement relies on DELETE … WHERE email = X only on the successful path. |
| M-1 |
auth/src/client.ts:11 + auth/src/tokens.ts:805-840 + actions/src/auth/setup.ts:232-244 |
oauth_clients.secret stored in plaintext. DB compromise hands attackers all client secrets, which (per Auth.parseToken flow) double as the AES-GCM passphrase for token-ID encryption. Laravel Passport hashes secrets in newer versions. |
| M-2 |
actions/src/auth/setup.ts:179-205 |
password_resets schema gaps: no expires_at, no email uniqueness, no foreign key. Combined with A-5, table is permissive enough to allow stale-token confusion. |
| M-3 |
auth/src/email-verification.ts reads email_verifications but actions/src/auth/setup.ts doesn't create it |
Either the migration files cover it (not visible from setup) or first call fails at runtime. |
| H-8 |
security/src/hash.ts:272-282 |
bcryptVerify / argon2Verify both delegate to Bun.password.verify, which auto-detects the algorithm. Passing a bcrypt hash to argon2Verify returns true. The function-signature contract is a lie. |
| M-9 |
pantry/ts-security-crypto/dist/index.js:8 |
PBKDF2 iterations only 100k. OWASP 2026 guidance for PBKDF2-SHA256 is ≥600k. The app key acts as the passphrase for every token-id encryption, so every AES-GCM call burns 100k PBKDF2 rounds — perf hit too. |
| M-10 |
security/src/key.ts:3-5 vs crypt.ts:9-14 |
key.ts generates base64:<...> but crypt.ts passes config.app.key directly to PBKDF2 without stripping. 8 fixed prefix bytes + 256-bit random = lowered entropy (not exploitable at 600k+ PBKDF2 but inconsistent). |
| L-2 |
auth/src/authentication.ts:107-114 |
validateClient short-circuit for non-existent client gives a timing oracle: executeTakeFirst() returns instantly for missing clients; existing clients run a buffer compare. |
| M-12 |
security/src/webhook.ts:228-232 |
verifyHmac algo=… prefix is stripped but not validated. Docstring claims "tolerates" the prefix but actually ignores it. Doesn't compromise security (HMAC is computed over digests with the algorithm parameter), but misleading. |
| L-6 |
security/src/webhook.ts:144-146 |
verifyStripe clock-skew check is asymmetric: rejects past timestamps outside tolerance but not future ones. Not exploitable without the secret, but worth a comment. |
Recommended fixes
- A-1: throw if
config.app.key is missing; never silently fall back to a static string. Add a doctor check that flags this at boot.
- A-4 (counter): persist
verification.authenticationInfo.newCounter on success; reject if not strictly greater than stored.
- A-4 (challenge): bind challenges to a server-stored nonce in a per-session row (or signed cookie); reject if the client-submitted challenge doesn't match the server's outstanding one.
- A-5 + M-2: delete prior rows before insert (like email-verification does); index/select by token-hash + email; add
expires_at TIMESTAMP NOT NULL to the schema with a unique constraint on email.
- M-1: hash
oauth_clients.secret at rest with Hash::make; store plaintext only in the response to auth:token; update validate paths to compare against the hash.
- M-3: add
email_verifications create statement to auth:setup or move it into a migration file and document the prerequisite.
- H-8:
bcryptVerify / argon2Verify should call detectAlgorithm(hash) and reject if it doesn't match the function name.
- M-9: raise PBKDF2 to ≥600k, OR switch to a per-process derived key cached after the initial derivation (one-time cost).
- M-10: strip the
base64: prefix in encrypt/decrypt callers before deriving.
- L-2: always run a dummy
timingSafeEqual against a fixed buffer when the client is missing.
INVESTIGATE
- The OAuth Authorization Code grant flow is absent entirely — the framework only implements Personal Access Tokens (Passport's PAT feature). If the roadmap intends real third-party OAuth (other apps acting on a user's behalf), the gap means there's no
state / PKCE / redirect-URI matching / consent-screen scaffolding to evaluate yet. oauth_clients.redirect is hardcoded to http://localhost (createPersonalAccessClient).
- TOTP (
authenticator.ts) has no scratch/recovery code support — lost-device → permanent 2FA lockout, often driving users to disable 2FA.
Found via an internal audit pass over router, query-builder, and auth. Filed separately: SQL injection, CORS/router info disclosure, auth state/session safety, query-builder broken behaviors, type-safety holes.
Severity: CRITICAL — token-forgeability when APP_KEY unset, passkey replay attacks, password-reset confusion, plaintext OAuth secrets, weak crypto defaults.
Findings
auth/src/email-verification.ts:32, 42'stacks-default-key'fallback for HMAC whenconfig.app.keyis unset. BothgenerateVerificationTokenandverifyTokenuse this literal. Anyone reading the source can forge email-verification tokens against installs that haven't setAPP_KEY.defaults/app/Actions/Auth/VerifyAuthenticationAction.ts:48-57verifyAuthenticationResponse(..., userPasskey.counter)but never persists the new counter back. WebAuthn anti-cloning defeated — a stolen authenticator can be reused indefinitely. Also acceptschallengefrom the request body rather than a server-stored nonce → challenge replay attacks.auth/src/password/reset.ts:62-75, 101-130, 138-200+actions/src/auth/setup.ts:179-205createResetToken()inserts a new row but never clears prior unused tokens for the same email. Verifier thenselectFrom('password_resets').where('email', '=', email).executeTakeFirst()is DB-order-dependent. Thepassword_resetsschema has noexpires_atcolumn and no unique key on email/token. Single-use enforcement relies onDELETE … WHERE email = Xonly on the successful path.auth/src/client.ts:11+auth/src/tokens.ts:805-840+actions/src/auth/setup.ts:232-244oauth_clients.secretstored in plaintext. DB compromise hands attackers all client secrets, which (perAuth.parseTokenflow) double as the AES-GCM passphrase for token-ID encryption. Laravel Passport hashes secrets in newer versions.actions/src/auth/setup.ts:179-205password_resetsschema gaps: noexpires_at, no email uniqueness, no foreign key. Combined with A-5, table is permissive enough to allow stale-token confusion.auth/src/email-verification.tsreadsemail_verificationsbutactions/src/auth/setup.tsdoesn't create itsecurity/src/hash.ts:272-282bcryptVerify/argon2Verifyboth delegate toBun.password.verify, which auto-detects the algorithm. Passing a bcrypt hash toargon2Verifyreturnstrue. The function-signature contract is a lie.pantry/ts-security-crypto/dist/index.js:8security/src/key.ts:3-5vscrypt.ts:9-14key.tsgeneratesbase64:<...>butcrypt.tspassesconfig.app.keydirectly to PBKDF2 without stripping. 8 fixed prefix bytes + 256-bit random = lowered entropy (not exploitable at 600k+ PBKDF2 but inconsistent).auth/src/authentication.ts:107-114validateClientshort-circuit for non-existent client gives a timing oracle:executeTakeFirst()returns instantly for missing clients; existing clients run a buffer compare.security/src/webhook.ts:228-232verifyHmacalgo=…prefix is stripped but not validated. Docstring claims "tolerates" the prefix but actually ignores it. Doesn't compromise security (HMAC is computed over digests with thealgorithmparameter), but misleading.security/src/webhook.ts:144-146verifyStripeclock-skew check is asymmetric: rejects past timestamps outside tolerance but not future ones. Not exploitable without the secret, but worth a comment.Recommended fixes
config.app.keyis missing; never silently fall back to a static string. Add a doctor check that flags this at boot.verification.authenticationInfo.newCounteron success; reject if not strictly greater than stored.expires_at TIMESTAMP NOT NULLto the schema with a unique constraint on email.oauth_clients.secretat rest withHash::make; store plaintext only in the response toauth:token; update validate paths to compare against the hash.email_verificationscreate statement toauth:setupor move it into a migration file and document the prerequisite.bcryptVerify/argon2Verifyshould calldetectAlgorithm(hash)and reject if it doesn't match the function name.base64:prefix inencrypt/decryptcallers before deriving.timingSafeEqualagainst a fixed buffer when the client is missing.INVESTIGATE
state/ PKCE / redirect-URI matching / consent-screen scaffolding to evaluate yet.oauth_clients.redirectis hardcoded tohttp://localhost(createPersonalAccessClient).authenticator.ts) has no scratch/recovery code support — lost-device → permanent 2FA lockout, often driving users to disable 2FA.Found via an internal audit pass over
router,query-builder, andauth. Filed separately: SQL injection, CORS/router info disclosure, auth state/session safety, query-builder broken behaviors, type-safety holes.