Fix broken cryptographic primitive protecting a secret in TanStack Start

A hash, cipher, or random source that cannot carry the weight it has been given: MD5 or SHA-1 over a password, a DES or ECB cipher, or Math.random() producing a token. Each has a drop-in replacement in the standard library, so the fix is small - the cost of not making it is that the protection is decorative.

high likely TanStack Start CWE-327 / OWASP A02:2021

The vulnerable pattern in TanStack Start

HIGH likely Broken cryptographic primitive protecting a secret A02:2021 app/lib/account.ts:5:35 3 │ export async function register(email: string, password: string) { 4 │ // weak-crypto: MD5 over a password. 5 │ const passwordHash = createHash('md5').update(password).digest('hex') │ ~~~~~ fast hash protecting a credential 6 │ 7 │ // weak-crypto: a session id anyone can predict from a few samples.

This finding comes from the TanStack Start fixture in the owlwarden test suite. This hash is fast, and speed is the attacker's advantage: a commodity GPU tries billions of candidates a second, so a leaked table of these hashes is a leaked table of the values behind them. Password hashing needs a deliberately slow algorithm with a per-value salt.

The corrected handler

Replace the primitive inside the server function.

import { randomBytes, randomUUID, scrypt } from 'node:crypto'

// Tokens and session ids: unpredictable, not merely random-looking.
const sessionId = randomUUID()
const resetToken = randomBytes(32).toString('base64url')

// Passwords: a slow hash with a per-password salt. bcrypt and argon2 are
// equally correct; scrypt needs no dependency.
const salt = randomBytes(16)
const hash = await new Promise<Buffer>((resolve, reject) =>
  scrypt(password, salt, 64, (error, key) => (error ? reject(error) : resolve(key))),
)

On a different runtime

The fix above is written for the runtime TanStack Start usually runs on. These are the runtimes where it would not run at all - an import that does not exist, or an API the host does not have - and what to write instead.

Workers and other fetch-API runtimes

There is no node:crypto on this runtime. Use the Web Crypto API, which is global. scrypt has no equivalent; PBKDF2 with a high iteration count is the replacement.

// No node:crypto here - this is the Web Crypto API, which every
// fetch-API runtime exposes globally as `crypto`.

// Tokens and session ids.
const sessionId = crypto.randomUUID()
const resetToken = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

// Passwords: scrypt is not available. PBKDF2 is, and needs a high
// iteration count to be worth anything.
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password),
  'PBKDF2', false, ['deriveBits'])
const hash = await crypto.subtle.deriveBits(
  { name: 'PBKDF2', salt, iterations: 600_000, hash: 'SHA-256' }, key, 256,
)

If you are not using TanStack Start

Use a slow, salted hash for passwords and a cryptographic random source for tokens. Both are in the Node standard library; neither needs a dependency.

Check your own repository

npx owlwarden scan
npx owlwarden explain weak-crypto

Runs on your machine. No account, no telemetry, no network unless you ask. In CI, SARIF uploads to code scanning and the exit code is the gate.

Other TanStack Start checks

Rules with a tested TanStack Start example.

weak-crypto for every framework / All rules / owlwarden