#412·coai

Unsalted password hash embedded in JWT payload enables offline password recovery

Author: 28HusCreated Aug 26, 2026Updated Aug 26, 2026

Unsalted password hash embedded in JWT payload enables offline password recovery

Summary

The JWT returned by login/register carries the account's password hash in the payload (auth/auth.go, "password": u.Password in the token claims). The hash is an unsalted SHA-256 (utils/encrypt.go Sha2Encrypt), and the JWT payload is only base64-encoded — not encrypted. The frontend persists the token to localStorage (app/src/store/auth.ts -> app/src/utils/memory.ts). Anyone who obtains a user's JWT (XSS, shared device, logs, network capture) can extract the hash and crack it offline, then log in with the plaintext password.

Affected components

  • auth/auth.go GenerateToken() (password hash written into JWT claims)
  • utils/encrypt.go Sha2Encrypt() (unsalted SHA-256)
  • auth/controller.go (login/register responses return the token)
  • app/src/store/auth.ts, app/src/utils/memory.ts (token persisted to localStorage)

Reproduction (verified on official docker-compose deployment)

  1. Log in or register any user; the response contains a JWT.
  2. base64url-decode the second segment of the JWT — it contains "password":"<hex hash>".
  3. Confirm <hex hash> equals sha256(plaintext) (it is the actual DB hash, validated server-side on every request).
  4. Run an offline dictionary/brute-force against the hash (unsalted SHA-256); weak passwords crack near-instantly (we reproduced a dictionary hit locally in under a second).

Negative control: the payload is not encrypted at all — the credential material is directly readable from the token.

Impact

  • Offline password recovery from a captured token; account takeover by logging in with the recovered plaintext — independent of the 30-day token expiry.
  • Amplified when users reuse the same password across sites.

Remediation

  • Remove the password hash from the JWT; carry only a random session identifier validated server-side.
  • Switch password storage to a salted KDF (bcrypt / argon2id / scrypt).
  • Shorten token lifetime and add a revocation mechanism; avoid persisting long-lived tokens in localStorage (prefer HttpOnly cookies).

Scope and limitations

  • Verified in a local isolated deployment (official image + default config). No production systems were touched.
  • Exploitation requires obtaining a user's JWT; the exposure surface is wide because the token is long-lived, sent in a plain request header, and stored in localStorage.