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.goGenerateToken()(password hash written into JWT claims)utils/encrypt.goSha2Encrypt()(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)
- Log in or register any user; the response contains a JWT.
- base64url-decode the second segment of the JWT — it contains
"password":"<hex hash>". - Confirm
<hex hash>equalssha256(plaintext)(it is the actual DB hash, validated server-side on every request). - 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.
Source: coaidev/coai