TX·09 · APPSEC / IDENTITY · LOGGED 2026·08 · 14 MIN
Account security: boring sessions beat clever tokens
TX·08 ended on a promise: the door is solid, so the next question is who gets a key. Digital Descent's beta is about to grow accounts, registration, login, and sessions that ride both HTTPS and the WebSockets from TX·04, which means I finally had to design authentication instead of deferring it. Two things surprised me. The standards have quietly killed half the "security" rituals everyone still performs. And the architecture answer is the unfashionable one: opaque server-side sessions, not JWTs, because a game that can ban people needs revocation that actually revokes.
Who gets a key
The threat model stays consistent with the whole series: automated adversaries, which for an account system means credential stuffing with breached password lists, bot signups, and session theft, plus one honest planning question that shapes everything downstream: assume the database leaks someday, what did we store, and how bad is that day? The answer worth designing for is blunt. A well-configured memory-hard hash buys real time against offline cracking, and it does nothing for the user who reused the same password everywhere. So the design has two jobs: make the stored material expensive to attack, and stop reused garbage from getting into it in the first place. Everything below serves one of those two, across the three transports the app actually has: server-rendered pages, the JSON API, and the sockets.
Passwords: the rules died, the hash matters
Start with what changed, because it invalidates most tutorials still circulating. NIST's digital identity guidelines went final in their fourth revision in mid-2025, and the password section is a demolition notice: composition rules are a SHALL NOT, forced periodic rotation is gone (rotate only on evidence of compromise), password hints and security questions are prohibited, paste must be allowed, and length replaced cleverness, a 15-character minimum when the password stands alone, 8 when it's one factor of several, with support up to at least 64 and all of Unicode welcome. The standard also requires screening candidates against breach corpora, which the abuse section below makes free. Any signup form still demanding one uppercase, one number, and one special character is now non-compliant with the standard it thinks it's honoring.
Storage is just as settled. OWASP's current floor is Argon2id at 19 MiB of memory, two iterations, parallelism of one, and the node binding makes the whole thing a dozen lines with a built-in upgrade path for the day parameters rise again:
import argon2 from "argon2";
const OPTS = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB, the OWASP floor; raise it if p95 stays under ~250ms
timeCost: 2,
parallelism: 1,
};
export const hashPassword = (plain) => argon2.hash(plain, OPTS);
export async function verifyPassword(stored, plain) {
const ok = await argon2.verify(stored, plain);
// params travel inside the hash string, so old hashes upgrade on next login
return { ok, needsRehash: ok && argon2.needsRehash(stored, OPTS) };
}
The hash string encodes its own salt and parameters, so there's no
separate salt column and no migration script when parameters bump:
verify with the old settings, notice needsRehash, and
rewrite on the next successful login. Benchmark on the real VPS,
not your desktop; the target is a hash that costs an attacker
dearly and your login route a couple hundred milliseconds.
What none of this protects: a user whose password is
password1! is cracked in the first second of any dump
regardless of algorithm, phishing and keyloggers capture plaintext
before hashing ever happens, and online guessing is a different
control entirely, which is what the abuse section is for.
Sessions, not tokens
Here's the shape of the app: one origin, three transports, and a
hard requirement that bans, logouts, and password changes take
effect now, not at token expiry. That requirement decides the
architecture by itself. A stateless JWT is valid until it expires,
and revoking one early means a server-side denylist checked on
every request, which is exactly the state you adopted JWTs to
avoid. Once you're keeping state anyway, the honest design is the
boring one: an opaque session ID in a cookie, backed by a store,
where logout is a delete and a ban is a delete with feelings. JWTs
keep a legitimate niche in short-lived service-to-service calls,
which this app doesn't have. And the adjacent cargo cult dies with
it: no tokens in localStorage, ever, where any XSS
reads them at leisure.
app.set("trust proxy", "loopback"); // the TX·08 rule: trust exactly one hop
app.use(session({
name: "__Host-sid", // prefix enforces Secure + Path=/ + no Domain
store: new RedisStore({ client, prefix: "sess:" }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // scripts never read it
secure: true,
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}));
The details doing quiet work: the __Host- name prefix
is enforced by browsers, a cookie carrying it must be Secure, set
by HTTPS, path-rooted, and domainless, which pins it to exactly
this host and shuts down subdomain games. HttpOnly
blunts cookie theft via script, and Lax is the right SameSite for
an app people arrive at from links. The store runs on the box, a
Valkey container on the TX·07 compose network, though at beta
scale a SQLite table is a perfectly respectable answer with one
less daemon; the cookie design stays identical either way. And the
one call everyone forgets: regenerate the session ID on login and
on any credential change, or you've left session fixation lying
around after all this effort.
CSRF in 2026: SameSite is a speed bump
SameSite=Lax stopped most drive-by cross-site POSTs, and the
folklore promptly declared CSRF solved. OWASP's current guidance is
more honest: Lax is a speed bump with known gaps, sibling
subdomains are same-site, top-level navigations sail through, so
the layered stack is still the answer. Mine has three cheap
layers. The cookie stays Lax. State-changing requests get checked
against Fetch Metadata, the Sec-Fetch-Site header
browsers now attach, where cross-site plus a
non-GET method equals a rejection, four lines of middleware. And
the HTML forms carry a synchronizer token, while the JSON API
requires a custom header, which cross-origin pages can't send
without a preflight they won't pass. None of this defends against
script running on your own origin; that's XSS, and it's why
TX·08's CSP is the most important auth control that isn't in this
article.
The session rides the socket
TX·04 authenticated sockets by carrying a token in the handshake
payload, which was the right call for the general case where the
client might not be a browser on your origin. With first-party
cookie sessions the picture simplifies beautifully: the WebSocket
upgrade is an HTTP GET, and it arrives carrying the same
__Host-sid cookie as every other request. So the
socket authenticates off the session, using the same middleware,
with the same revocation:
server.on("upgrade", (req, socket, head) => {
// the TX·04 origin allowlist stays; cookies make CSWSH real again without it
if (req.headers.origin !== "https://game.example") return socket.destroy();
// the same session middleware that guards HTTP guards the socket
sessionMiddleware(req, {}, () => {
if (!req.session?.userId) return socket.destroy();
wss.handleUpgrade(req, socket, head, (ws) => {
ws.userId = req.session.userId;
wss.emit("connection", ws, req);
});
});
});
Note what did not relax: the origin check matters more now, not
less, because a cookie-authenticated handshake is exactly the
cross-site WebSocket hijacking setup TX·04 dissected. And the
revocation story finally completes the pattern that article
started: killing a session in the store means no new sockets, and
tracking live sockets per userId means a ban can drop
the existing ones mid-frame. Expiry as an event, now with the
session as the single source of truth.
Stuffing, bots, and the lockout trap
The division of labor follows TX·08: the edge keeps its coarse per-IP limits, sized for CGNAT honesty, and the app owns the limits that need identity, because only the app knows which account is under attack. Per-account throttling with backoff is the primary control, and it comes with a trap worth naming: a hard lockout is a denial-of-service button, an attacker who can't guess your password can still lock you out all day. So throttle and add friction rather than freeze, return a generic 429 that confirms nothing about whether the account exists, and know the ceiling: NIST caps consecutive failures at one hundred before the authenticator must be disabled, which is the legal limit, not the speed you should drive:
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10, // friction long before NIST's 100-attempt ceiling
standardHeaders: "draft-8",
keyGenerator: (req) => `login:${normalizeEmail(req.body.email)}`, // per account, not per IP
handler: (req, res) => res.status(429).json({ error: "too_many_attempts" }),
});
Against stuffing specifically, the highest-leverage free control is refusing breached passwords at the door. Have I Been Pwned's Pwned Passwords API is free, keyless, and built on k-anonymity: you hash locally, send only the first five characters of the SHA-1, get back several hundred matching suffixes, and compare on your own CPU. The password never leaves the box:
import crypto from "node:crypto";
export async function pwnedCount(plain) {
const sha1 = crypto.createHash("sha1").update(plain).digest("hex").toUpperCase();
const prefix = sha1.slice(0, 5), suffix = sha1.slice(5);
// only these five characters ever leave the server
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { "Add-Padding": "true" },
});
for (const line of (await res.text()).split("\n")) {
const [suf, count] = line.trim().split(":");
if (suf === suffix) return Number(count) || 1;
}
return 0;
}
Bot signups get the cheapest effective stack: a self-hosted proof-of-work challenge, invisible to humans and expensive at scale, plus email verification before an account can do anything social. Classic CAPTCHAs sit deliberately last: modern image puzzles exclude real users on accessibility grounds while current AI solves them better than humans do, which is a poor trade to lead with. And the honesty clause for the whole section: none of this stops a slow, distributed campaign staying under every threshold, screening can't catch a password breached yesterday, and proof-of-work authenticates nobody, it only prices out laziness. The remainder is detection's job.
Reset flows are where accounts die
Password reset is authentication's back door, and it gets attacked as one. The flow that holds up: generate a 256-bit random token, store only its hash, expire it inside an hour, burn it on first use, and change nothing about the account until the token is presented; OWASP's cheat sheet says it in one line, "Do not make a change to the account until a valid token is presented." Both the request and confirm endpoints answer identically for existing and missing accounts, with uniform timing, because a reset form that says "no such user" is an enumeration oracle with a friendly font:
function issueResetToken() {
const token = crypto.randomBytes(32).toString("base64url");
const hash = crypto.createHash("sha256").update(token).digest("hex");
// the token goes in the email; only the hash touches the database
return { token, hash };
}
Around it, the lifecycle rules that turn incidents into non-events. Email change requires a live re-auth, confirms to the new address, and notifies the old one, so an account can't be silently walked out the side door. Any credential change destroys every other session and regenerates the current one, which is where the server-side session design pays off again: "log out everywhere" is a store query against the user's session set, not a prayer that tokens expire. And the free MFA tier is TOTP, a standard authenticator-app code, secret encrypted at rest, hashed one-time recovery codes issued at enrollment. Be honest about its ceiling: TOTP phishes through a real-time relay, it's better than nothing by a wide margin and it isn't the phishing-resistant tier. That tier is next. The graveyard items, stated once: security questions are prohibited outright, emailed plaintext passwords are malpractice, and a reset link that lives forever is a standing skeleton key.
Passkeys: real, optional, recoverable
Passkeys earned their hype: the WebAuthn Level 3 spec is at the finish line as of this summer, the three big platform vaults sync them, and the ceremony's security property is structural, a keypair bound to your exact origin, private half never leaving the authenticator, which makes the credential unphishable in a way no shared secret can be, the same exact-origin-match discipline TX·04 preached, now enforced by hardware. The server-side integration is a well-maintained library, a challenge stored server-side and single-use, attestation set to none because a game beta has no business fingerprinting authenticators, and on success the normal session from three sections ago, passkeys change how the key turns, not what it opens.
The judgment call is deployment posture, and mine is deliberately unfashionable: password plus optional passkey, not passkey-only. The catch nobody markets is recovery. A lost passkey with no fallback is a locked-out beta tester, synced passkeys inherit the security of the user's cloud account, and every recovery path you add hands the account's real security to the weakest of them, usually email. For a one-developer beta, the honest configuration is: password remains the recovery-friendly baseline, passkeys are offered as the faster, phishing-resistant upgrade, enrolling users are nudged to register two, and passkey-first waits until there's recovery tooling and support bandwidth to stand behind it. What passkeys don't fix: a compromised device approves ceremonies just fine, and the server still has to verify and mint sessions correctly. The strongest credential in the world protects a session that must still be worth protecting.
The login joins the SOC
TX·06 built the success-after-failures rule for SSH; the application login needs the identical logic, and it doesn't exist until you build it, because a SIEM doesn't treat web 401s as brute force on its own. The app emits structured auth events, outcomes and identifiers, never passwords or tokens: login success and failure with account and source, throttle triggers, resets requested and completed, email changes, MFA and passkey enrollment or removal. A decoder and two rules turn that stream into the one page that matters:
<group name="webauth,">
<rule id="100010" level="5">
<decoded_as>appjson</decoded_as>
<field name="event">login_failed</field>
<description>App login failure for $(account)</description>
<mitre><id>T1110</id></mitre>
</rule>
<rule id="100011" level="12" frequency="10" timeframe="300">
<if_matched_sid>100010</if_matched_sid>
<same_field>account</same_field>
<field name="event">login_success</field>
<description>Login success after repeated failures for $(account)</description>
<mitre><id>T1110</id><id>T1078</id></mitre>
</rule>
</group>
That transition, brute force becoming a valid account, T1110 rolling over into T1078, is the single highest-signal event an account system can emit, and it should page. Around it, the supporting cast with honest weights: reset storms against one account are worth an alert, an MFA method removed followed shortly by a password change is a takeover chain in progress (T1556), and impossible travel is, at hobby scale, mostly a VPN detector, log it for context and never page on it. Session-cookie theft (T1539) is real and hard to detect without brittle IP binding that CGNAT breaks; the honest posture is re-auth on sensitive actions rather than pretending to detect the undetectable. TX·06's alert budget still governs: a few actionable pages a day, and this rule set fits inside it with room to spare.
The checklist
The account system, compressed into what I now hold it to:
- Argon2id at 19 MiB / t=2 / p=1 with the
needsRehashupgrade path; bcrypt verifies legacy hashes only, writes nothing new - NIST-current policy: 15-character minimum standalone (8 within MFA), 64+ and paste allowed; composition rules, forced rotation, and security questions deleted
- Every new and changed password screened against Pwned Passwords via k-anonymity, with a rejection message that teaches
- Opaque sessions in a
__Host-cookie, HttpOnly, Secure, Lax; store lives on the box; nothing auth-shaped inlocalStorage - Session ID regenerated on login and on every credential change
- CSRF layered: Lax cookie, Fetch-Metadata rejection of cross-site state changes, synchronizer tokens on forms, custom header on the JSON API
- WebSocket upgrades authenticated off the same session cookie behind the same origin allowlist; sockets tracked per user so revocation drops live connections
- Per-account throttling with backoff and generic 429s; friction long before NIST's 100-attempt ceiling; no hard-lockout DoS button
- Signups gated by self-hosted proof-of-work plus email verification; CAPTCHA held as a reluctant fallback tier
- Reset tokens 256-bit, stored hashed, single-use, under an hour; uniform responses and timing everywhere an account could be probed
- Credential changes destroy all other sessions; "log out everywhere" is a working store query; email changes confirm new and notify old
- Optional TOTP with encrypted secrets and hashed recovery codes; optional passkeys with two credentials encouraged and a real recovery path
- Structured auth events in the SIEM; success-after-failures pages, reset storms alert, impossible travel logs quietly
The through-line, one more time: every architectural choice here, sessions over tokens, throttles over lockouts, passwords kept beside passkeys, came from asking what happens on the bad day, the ban, the breach, the lost device, rather than the good one. That risk-first framing is precisely what the identity standards now formalize and what application security interviews probe for. TX·05's auth log is still filling with guesses from the whole internet. The difference as of this article is that there are real accounts behind the door, the hashes would hold, the sessions revoke, and the SOC knows the exact shape of a key being stolen.