Use this skill when building or reviewing anything to do with who a user is and how long they stay that way — login, registration, password reset or change, email change, invitations, sessions, API tokens, second factor, logout, remember-me, a captcha on a form. Also when the user reports credential stuffing, an account enumeration finding, a session that never expires, a password change that did not log other devices out, a lockout that never fires, or asks what a new application needs before it opens to the public. It gives the control, the reason it is shaped that way, and the ways each one is commonly present but ineffective. Do not use it for authorisation and ownership (padosoft-tenant-isolation) or for the whole security posture (padosoft-security-baseline).
The controls below are the ones that are typically present and ineffective: a limiter with the wrong key,
a lockout event nothing emits, a session lifetime that is a sliding window, a revocation that covers one
door out of three.
The rule: identity is a set of doors, and hardening means none of them disagrees with the others.
1. Rate limiting needs two keys, not one
per address only → distributed credential stuffing walks through
per account only → N accounts get swept in parallel from one machine
both → the attack has to be both slow and narrow
Key the per-account limit on a hash of the normalised identifier, never the address itself: a limiter
backed by a shared cache otherwise stores your users' email addresses in plaintext, in a second system,
for ever.
Put the client address into the per-account key as well, or an attacker can lock a real customer out
of their own account at will. Denial of service against a named user is a cheap attack.
Limit the reset and the contact forms too. They send mail on somebody else's behalf.
Check that a lockout actually emits an event. Many frameworks only emit it from their own built-in
throttling helper; a hand-rolled limiter raises nothing, so the detection rule that counts lockouts can
never fire and nobody notices that nothing is noticing.
2. Nothing may enumerate accounts
The response must be identical whether or not the account exists — same body, same status, same timing
class. That applies to every surface, and the ones that get forgotten are the later ones:
Surface
The tell
Password reset
"no account with that address"
Registration
"this address is already registered"
Email change
the same message, on the profile screen
Invitations
a mask that shows which invitees are already members
Account created during checkout
a different path when the address is known
Token refresh
a refusal that says why — expired is not the same as revoked, and you just said which
Error messages on authentication forms are deliberately generic: a precise message is what tells a script it
has found a valid account.
3. Passwords
Length over arbitrary complexity. Modern guidance prefers a long minimum and a breach check to
mandatory character classes.
Reject known-breached passwords at every point a password is chosen, using a range query so the
password never leaves your server.
Deliberately exclude the login itself. Rejecting an existing password at sign-in locks the user out of
their own account, and they cannot fix it from there. Exclude bulk imports too, or you make one network
call per row.
Fail open when the breach service is unreachable — and write down that you did.
Confirm the address before the account can be used, or registration is a way to occupy somebody else's
identity.
4. Sessions
Rotate the session identifier at login. Most frameworks do; the risk is a custom login path that does
not.
Rotate it again on any privilege change — impersonation, role switch, elevation.
A sliding lifetime is not a timeout. A session that renews on every request stays alive for ever as
long as something touches it every so often, which is exactly what a stolen session does. Add an
absolute lifetime measured from the first authenticated request.
Turning an absolute lifetime on without warning throws out everyone mid-task, so it ships off with a
value the team chooses — but the floor lives in code, because a security boundary is not a setting.
It does not cover a stolen long-lived cookie, which re-authenticates into a brand-new session. Those
need their own revocation.
Binding a session to a client fingerprint raises the cost of reusing a stolen cookie. Measure what it
actually rejects before counting on it.
5. Revocation has to be one thing
Changing a password usually has three doors — the API, the reset link, the account page — and they routinely
produce three different outcomes: one revokes the tokens, one sets a flag and leaves them alive, one does
nothing. The most-used door is rarely the most careful one.
One service, called by all of them.
Delete tokens one by one rather than in a single bulk statement, so the model observer that clears the
cached copy actually runs. A bulk delete leaves a valid token in the cache.
Revoking web sessions is separate from revoking API tokens, and needs the middleware that tracks them
to be active.
Record who revoked what, and why. A disconnection whose cause is unknown is indistinguishable from an
attack.
6. Second factor, and why it stays off for years
The mechanism is usually already installed and disabled. What is missing is never the code: it is the answer
to how many accounts would be locked out if we turned this on tomorrow.
audit (who has no second factor) → enrol those accounts → enable
Take the secret, the recovery codes and the long-lived tokens out of mass assignment, with a test that
keeps them out: those field lists are commonly regenerated by a scaffolding tool, and the regeneration
puts them back. Writable, they allow substituting somebody's second factor, forging a persistent login and
regenerating a confirmation token.
Verifying a code is not a lifecycle. Enrolment must confirm possession before activation, recovery
codes are stored as non-reversible hashes and consumed atomically, and the secret needs a real protector.
Require re-authentication for privileged changes even inside a live session — editing users, roles or
permissions is what a stolen session is worth the most for.
7. A captcha is friction, not a control
The strong control against credential stuffing is the rate limit. A modern score-based captcha is solved
commercially for a few units of currency per thousand: it raises the cost of mass automation, and it does
not stop a determined attacker. Everything below follows from that premise.
The score is the verdict, not the success flag. A success field usually means only that the token was
well-formed and unexpired — it is true for an obvious bot. Stopping there rejects whoever sends no token
and admits every bot that generates one, which takes a single call with the public site key already in
your page.
A decimal threshold read through an integer cast becomes zero, and zero means off, silently, with
nothing to indicate it. This applies to every decimal setting you own, not only this one.
Fail open when the verifier does not answer — a timeout, a DNS failure, an egress firewall — and log
it. Blocking means losing real traffic because a third party has a problem, in exchange for nothing:
the attacker is not coming through the form. Log a provider-side client error at a higher level than a
timeout: that one is persistent and someone must look.
Both fail-open choices are acceptable only while the rate limit holds. Write the dependency down, so
removing the limiter re-opens the question.
The verification timeout is a real tradeoff. Too long and a worker is held for its whole duration —
under a bot the pool empties and the captcha becomes an availability multiplier. Too short and every
transient slowdown becomes a systematic fail-open, disabling the control exactly when the network is under
strain.
No half switch. A toggle that disables the server-side verification while the page keeps calling the
provider looks like a decision and is not one.
Do not add a conditional in front of forms that already have it, or they stay unprotected until
somebody remembers to turn a setting on.
8. Telling the user something happened
A notification on a sign-in from a new context is one of the few controls that catches an attacker who has
everything right. It is also the control most likely to be switched off for noise, so:
Fingerprint the context, do not store it. A keyed hash of a device family plus a truncated network —
never the full address, which is personal data you now keep for as long as the table lives.
Strip version numbers from the client string and truncate the address, or every browser update is a
new context and every notification is a false alarm.
The first context of an account never notifies, or the day you deploy everybody gets an email.
Anti-flood per account.
Queue it, and make the listener incapable of failing the login. A notification path that can throw
turns a security feature into an outage.
9. Tokens
Classes, not one shape. A staff token and a customer token have different lifetimes, scopes and
revocation rules; one table with one meaning is how a customer token ends up with staff reach.
An identifier is never the only factor. A value that is both the name of a thing and the proof you may
have it is a credential in a URL, and it will be in a log, a referrer and a screenshot.
Mock or test authentication must be impossible in production, enforced at startup and failing fast,
not by a branch somewhere — see padosoft-environment-gating.
Gotchas
The limiter that protects the login does not protect the endpoint that logs you in a second way.
Mobile entry points, single-page flows and legacy paths are separate doors.
A uniform response that takes a different amount of time is not uniform.
"It is behind the edge" is not an authentication control, and it is only true while the origin refuses
everything else.
A security event nobody emits cannot be alerted on. Check the event exists before building the alert —
see padosoft-failure-visibility.
A second factor enabled without an enrolment audit locks out exactly the people who cannot call you.
The most-used door is usually the least careful one, because it was written first and has the most
legacy behind it.
Checklist
Login limited by account and address; the account key hashed; the address inside the account key
Reset and mail-sending forms limited too
A lockout emits an event, and something counts it
Reset, registration, email change, invitation and refusal responses reveal nothing about existence
Breached-password rejection at every choice point, excluded at login on purpose, failing open
Address confirmed before the account is usable
Session rotated at login and at privilege change; absolute lifetime in addition to the sliding one
One revocation service used by every door; tokens deleted individually; cache invalidated
Second factor: audit first, then enrolment, then enabling; secrets out of mass assignment, with a test
Re-authentication required before changing users, roles or permissions
Captcha judged on the score; decimal threshold read without an integer cast; fail-open documented
No test or mock authentication reachable in production
Final report
Doors reviewed: <login paths, reset, registration, API, mobile>
Rate limits: account <key, rate> · address <rate> · lockout event: emitted | none
Enumeration: <surfaces checked, and what each returns>
Session: rotation <…> · sliding <…> · absolute <…> · binding <…>
Revocation: one service | <which doors diverge>
Second factor: <installed? enabled? accounts without it: n>
Anti-automation: <control, threshold, fail-open and what carries the weight>
Open: <…>
1---2name: padosoft-auth-hardening3description: Use this skill when building or reviewing anything to do with who a user is and how long they stay that way — login, registration, password reset or change, email change, invitations, sessions, API tokens, second factor, logout, remember-me, a captcha on a form. Also when the user reports credential stuffing, an account enumeration finding, a session that never expires, a password change that did not log other devices out, a lockout that never fires, or asks what a new application needs before it opens to the public. It gives the control, the reason it is shaped that way, and the ways each one is commonly present but ineffective. Do not use it for authorisation and ownership (padosoft-tenant-isolation) or for the whole security posture (padosoft-security-baseline).4license: MIT5---67# Authentication hardening89The controls below are the ones that are typically *present and ineffective*: a limiter with the wrong key,10a lockout event nothing emits, a session lifetime that is a sliding window, a revocation that covers one11door out of three.1213**The rule: identity is a set of doors, and hardening means none of them disagrees with the others.**1415---1617## 1. Rate limiting needs two keys, not one1819```text20per address only → distributed credential stuffing walks through21per account only → N accounts get swept in parallel from one machine22both → the attack has to be both slow and narrow23```2425- Key the per-account limit on a **hash of the normalised identifier**, never the address itself: a limiter26 backed by a shared cache otherwise stores your users' email addresses in plaintext, in a second system,27 for ever.28- Put the client address **into** the per-account key as well, or an attacker can lock a real customer out29 of their own account at will. Denial of service against a named user is a cheap attack.30- **Limit the reset and the contact forms too.** They send mail on somebody else's behalf.31- **Check that a lockout actually emits an event.** Many frameworks only emit it from their own built-in32 throttling helper; a hand-rolled limiter raises nothing, so the detection rule that counts lockouts can33 never fire and nobody notices that nothing is noticing.3435## 2. Nothing may enumerate accounts3637The response must be identical whether or not the account exists — same body, same status, **same timing38class**. That applies to every surface, and the ones that get forgotten are the later ones:3940| Surface | The tell |41|---|---|42| Password reset | "no account with that address" |43| Registration | "this address is already registered" |44| Email change | the same message, on the profile screen |45| Invitations | a mask that shows which invitees are already members |46| Account created during checkout | a different path when the address is known |47| Token refresh | a refusal that says **why** — expired is not the same as revoked, and you just said which |4849Error messages on authentication forms are deliberately generic: a precise message is what tells a script it50has found a valid account.5152## 3. Passwords5354- **Length over arbitrary complexity.** Modern guidance prefers a long minimum and a breach check to55 mandatory character classes.56- **Reject known-breached passwords at every point a password is chosen**, using a range query so the57 password never leaves your server.58- **Deliberately exclude the login itself.** Rejecting an existing password at sign-in locks the user out of59 their own account, and they cannot fix it from there. Exclude bulk imports too, or you make one network60 call per row.61- **Fail open when the breach service is unreachable** — and write down that you did.62- **Confirm the address before the account can be used**, or registration is a way to occupy somebody else's63 identity.6465## 4. Sessions6667- **Rotate the session identifier at login.** Most frameworks do; the risk is a custom login path that does68 not.69- **Rotate it again on any privilege change** — impersonation, role switch, elevation.70- **A sliding lifetime is not a timeout.** A session that renews on every request stays alive for ever as71 long as something touches it every so often, which is exactly what a stolen session does. Add an72 **absolute** lifetime measured from the first authenticated request.73- Turning an absolute lifetime on without warning throws out everyone mid-task, so it ships **off** with a74 value the team chooses — but the floor lives in code, because a security boundary is not a setting.75- **It does not cover a stolen long-lived cookie**, which re-authenticates into a brand-new session. Those76 need their own revocation.77- **Binding a session to a client fingerprint** raises the cost of reusing a stolen cookie. Measure what it78 actually rejects before counting on it.7980## 5. Revocation has to be one thing8182Changing a password usually has three doors — the API, the reset link, the account page — and they routinely83produce three different outcomes: one revokes the tokens, one sets a flag and leaves them alive, one does84nothing. The most-used door is rarely the most careful one.8586- **One service, called by all of them.**87- **Delete tokens one by one** rather than in a single bulk statement, so the model observer that clears the88 cached copy actually runs. A bulk delete leaves a valid token in the cache.89- **Revoking web sessions is separate** from revoking API tokens, and needs the middleware that tracks them90 to be active.91- **Record who revoked what, and why.** A disconnection whose cause is unknown is indistinguishable from an92 attack.9394## 6. Second factor, and why it stays off for years9596The mechanism is usually already installed and disabled. What is missing is never the code: it is the answer97to *how many accounts would be locked out if we turned this on tomorrow*.9899```text100audit (who has no second factor) → enrol those accounts → enable101```102103- **Take the secret, the recovery codes and the long-lived tokens out of mass assignment**, with a test that104 keeps them out: those field lists are commonly regenerated by a scaffolding tool, and the regeneration105 puts them back. Writable, they allow substituting somebody's second factor, forging a persistent login and106 regenerating a confirmation token.107- **Verifying a code is not a lifecycle.** Enrolment must confirm possession before activation, recovery108 codes are stored as non-reversible hashes and consumed atomically, and the secret needs a real protector.109- **Require re-authentication for privileged changes** even inside a live session — editing users, roles or110 permissions is what a stolen session is worth the most for.111112## 7. A captcha is friction, not a control113114The strong control against credential stuffing is the rate limit. A modern score-based captcha is solved115commercially for a few units of currency per thousand: it raises the cost of mass automation, and it does116not stop a determined attacker. Everything below follows from that premise.117118- **The score is the verdict, not the success flag.** A success field usually means only that the token was119 well-formed and unexpired — it is true for an obvious bot. Stopping there rejects whoever sends no token120 and admits every bot that generates one, which takes a single call with the public site key already in121 your page.122- **A decimal threshold read through an integer cast becomes zero**, and zero means off, silently, with123 nothing to indicate it. This applies to every decimal setting you own, not only this one.124- **Fail open when the verifier does not answer** — a timeout, a DNS failure, an egress firewall — and log125 it. Blocking means losing real traffic because a third party has a problem, in exchange for nothing:126 the attacker is not coming through the form. Log a provider-side client error at a **higher** level than a127 timeout: that one is persistent and someone must look.128- **Both fail-open choices are acceptable only while the rate limit holds.** Write the dependency down, so129 removing the limiter re-opens the question.130- **The verification timeout is a real tradeoff.** Too long and a worker is held for its whole duration —131 under a bot the pool empties and the captcha becomes an availability multiplier. Too short and every132 transient slowdown becomes a systematic fail-open, disabling the control exactly when the network is under133 strain.134- **No half switch.** A toggle that disables the server-side verification while the page keeps calling the135 provider looks like a decision and is not one.136- **Do not add a conditional in front of forms that already have it**, or they stay unprotected until137 somebody remembers to turn a setting on.138139## 8. Telling the user something happened140141A notification on a sign-in from a new context is one of the few controls that catches an attacker who has142everything right. It is also the control most likely to be switched off for noise, so:143144- **Fingerprint the context, do not store it.** A keyed hash of a device family plus a truncated network —145 never the full address, which is personal data you now keep for as long as the table lives.146- **Strip version numbers from the client string and truncate the address**, or every browser update is a147 new context and every notification is a false alarm.148- **The first context of an account never notifies**, or the day you deploy everybody gets an email.149- **Anti-flood per account.**150- **Queue it, and make the listener incapable of failing the login.** A notification path that can throw151 turns a security feature into an outage.152153## 9. Tokens154155- **Classes, not one shape.** A staff token and a customer token have different lifetimes, scopes and156 revocation rules; one table with one meaning is how a customer token ends up with staff reach.157- **An identifier is never the only factor.** A value that is both the name of a thing and the proof you may158 have it is a credential in a URL, and it will be in a log, a referrer and a screenshot.159- **Mock or test authentication must be impossible in production**, enforced at startup and failing fast,160 not by a branch somewhere — see **`padosoft-environment-gating`**.161162---163164## Gotchas165166- **The limiter that protects the login does not protect the endpoint that logs you in a second way.**167 Mobile entry points, single-page flows and legacy paths are separate doors.168- **A uniform response that takes a different amount of time is not uniform.**169- **"It is behind the edge" is not an authentication control**, and it is only true while the origin refuses170 everything else.171- **A security event nobody emits cannot be alerted on.** Check the event exists before building the alert —172 see **`padosoft-failure-visibility`**.173- **A second factor enabled without an enrolment audit locks out exactly the people who cannot call you.**174- **The most-used door is usually the least careful one**, because it was written first and has the most175 legacy behind it.176177## Checklist178179- [ ] Login limited by account **and** address; the account key hashed; the address inside the account key180- [ ] Reset and mail-sending forms limited too181- [ ] A lockout emits an event, and something counts it182- [ ] Reset, registration, email change, invitation and refusal responses reveal nothing about existence183- [ ] Breached-password rejection at every choice point, excluded at login on purpose, failing open184- [ ] Address confirmed before the account is usable185- [ ] Session rotated at login and at privilege change; absolute lifetime in addition to the sliding one186- [ ] One revocation service used by every door; tokens deleted individually; cache invalidated187- [ ] Second factor: audit first, then enrolment, then enabling; secrets out of mass assignment, with a test188- [ ] Re-authentication required before changing users, roles or permissions189- [ ] Captcha judged on the score; decimal threshold read without an integer cast; fail-open documented190- [ ] New-context notification fingerprinted, version-stripped, first-context silent, queued, non-fatal191- [ ] No test or mock authentication reachable in production192193## Final report194195```196Doors reviewed: <login paths, reset, registration, API, mobile>197Rate limits: account <key, rate> · address <rate> · lockout event: emitted | none198Enumeration: <surfaces checked, and what each returns>199Session: rotation <…> · sliding <…> · absolute <…> · binding <…>200Revocation: one service | <which doors diverge>201Second factor: <installed? enabled? accounts without it: n>202Anti-automation: <control, threshold, fail-open and what carries the weight>203Open: <…>204```
Run npx skillmds@latest add padosoft/padosoft-auth-hardening in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Use this skill when building or reviewing anything to do with who a user is and how long they stay that way — login, registration, password reset or change, email change, invitations, sessions, API tokens, second factor, logout, remember-me, a captcha on a form. Also when the user reports credential stuffing, an account enumeration finding, a session that never expires, a password change that did not log other devices out, a lockout that never fires, or asks what a new application needs before it opens to the public. It gives the control, the reason it is shaped that way, and the ways each one is commonly present but ineffective. Do not use it for authorisation and ownership (padosoft-tenant-isolation) or for the whole security posture (padosoft-security-baseline). It is listed under Security on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: reads secrets. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free. This skill is licensed under MIT.
padosoft (@padosoft) published this skill. Their other Agent Skills are listed on their SkillMD profile.