Password Reset
@aooth/auth ships the primitives for password reset. The full workflow — the HTTP routes, the rate limits, the audit trail, the multi-step form, the freshly-logged-in countdown — lives in @aooth/auth-moost. This page covers what you'd compose if you were building it yourself.
There are four steps, and each maps to a primitive in this package or in @aooth/user.
The four-step recipe
| Step | Primitive | Package |
|---|---|---|
| 1. Token | generateMagicLinkToken() | @aooth/auth |
| 2. Storage | CredentialStore.persist(state, ttl) + consume(token) | @aooth/auth |
| 3. Delivery | EmailSender.send({ kind: 'recovery.magicLink', ... }) | @aooth/auth (interface) |
| 4. Verification | store.consume(token) → users.changePassword → auth.revokeAllForUser → auth.issue | @aooth/auth + @aooth/user |
Step 1 — generate the token
import { generateMagicLinkToken } from "@aooth/auth";
const token = generateMagicLinkToken(); // 43 chars, base64url, URL-safe32 bytes of CSPRNG. See Magic Links for the format details.
Step 2 — store it with a TTL
AuthCredential.store is private readonly, so keep your own reference to the store after construction and call persist / consume on it directly:
const store = new CredentialStoreAtscriptDb({ table });
const auth = new AuthCredential({ store, accessTtl: 15 * 60 * 1000 });
await store.persist(
{
userId,
issuedAt: now,
expiresAt: now + ttl,
kind: "magic.recovery", // free string — your discriminator
},
ttl, // 10–15 min is typical
);Use the same CredentialStore you already have — the magic-link token lives in the same table / Redis namespace / JWT denylist as the rest of your credentials. See Magic Links — Storage pattern.
Don't reuse kind: 'access' for single-use tokens
When a single-use token is persisted with kind: 'access' (or no kind), it can be presented as a bearer token to auth.validate() — the validator only filters on kind === 'refresh' and lets everything else through. A leaked or mis-routed recovery token then becomes a working access token.
Always either:
- Persist with a distinct
kind('magic.recovery','magic.login','magic.invite', etc.) and check it explicitly afterconsume, OR - Discriminate by
metadata.labeland have your auth guard reject any unknown label.
// guard
const state = await auth.validate(presentedToken);
if (!state) throw new HttpError(401);
if (state.kind && state.kind !== "access") throw new HttpError(401); // reject magic kindsStep 3 — send the email
await emailSender.send({
kind: "recovery.magicLink",
recipient: user.email,
url: buildMagicLinkUrl("recovery.magicLink", token),
expiresAt: now + ttl,
username: user.email,
});EmailSender is an interface — the package ships no implementation. See Delivery for the contract and example SES / SendGrid implementations.
Step 4 — verify, change password, re-issue
const state = await store.consume(token); // use the same `store` reference from step 2
if (!state || state.kind !== "magic.recovery") {
throw new HttpError(401, "invalid or expired link");
}
await users.changePassword(state.userId, newPassword);
await auth.revokeAllForUser(state.userId); // log out everywhere
const { accessToken } = await auth.issue(state.userId, {
/* claims */
}); // auto-loginconsume is atomic — retrieve and revoke in one operation. Two concurrent submissions of the same link cannot both succeed.
The full minimal flow
import {
AuthCredential,
CredentialStoreAtscriptDb,
generateMagicLinkToken,
type EmailSender,
type BuildMagicLinkUrl,
} from "@aooth/auth";
declare const users: {
findUserIdByEmail(email: string): Promise<string | null>;
changePassword(userId: string, newPassword: string): Promise<void>;
};
declare const emailSender: EmailSender;
declare const table: import("@aooth/auth/atscript-db").AuthCredentialTable;
// Bind the store separately — `AuthCredential.store` is `private readonly`.
const store = new CredentialStoreAtscriptDb({ table });
const auth = new AuthCredential<{ roles: string[] }>({
store,
accessTtl: 15 * 60 * 1000,
});
const buildMagicLinkUrl: BuildMagicLinkUrl = (_kind, token) =>
`https://app.example.com/auth/recovery/finish?t=${token}`;
// ─── start of recovery ──────────────────────────────────────
async function startRecovery(email: string) {
const userId = await users.findUserIdByEmail(email);
if (!userId) return; // don't leak existence
const token = generateMagicLinkToken();
const now = Date.now();
const ttl = 15 * 60 * 1000;
await store.persist({ userId, issuedAt: now, expiresAt: now + ttl, kind: "magic.recovery" }, ttl);
await emailSender.send({
kind: "recovery.magicLink",
recipient: email,
url: buildMagicLinkUrl("recovery.magicLink", token),
expiresAt: now + ttl,
});
}
// ─── finish of recovery ─────────────────────────────────────
async function finishRecovery(token: string, newPassword: string) {
const state = await store.consume(token);
if (!state || state.kind !== "magic.recovery") {
throw new HttpError(401, "invalid or expired link");
}
await users.changePassword(state.userId, newPassword);
await auth.revokeAllForUser(state.userId);
const { accessToken } = await auth.issue(state.userId, {
claims: { roles: ["user"] },
metadata: { ip: "…", userAgent: "…" },
});
return { userId: state.userId, accessToken };
}That's the entire recovery flow — about 40 lines on top of the primitives.
Why revokeAllForUser before issue
The order matters. If you issue first then revokeAllForUser, you've just revoked the credential you wanted the user to log in with. The correct order:
consume(token)— kill the magic link.changePassword(userId)— update the credential.revokeAllForUser(userId)— bump the per-user revocation epoch (or delete every row). Every previously-issued access and refresh token is dead.issue(userId, …)— issue a fresh credential. ItsiatMsis>=the epoch, so it survives the gate.
This is the same-millisecond >= pattern from Refresh. It only works because the gate is >= not >.
Why log out everywhere after a password change
The password is the recovery mechanism. If an attacker stole sessions before the legitimate user noticed, they're still inside. A password change must invalidate all of them — otherwise the reset is theatre.
Don't skip revokeAllForUser
Even if your access tokens are short-lived, the refresh tokens are not. A stale refresh token survives a password change unless revokeAllForUser is called. Always cascade.
Token TTL choice
| Use case | TTL |
|---|---|
| Password reset link | 10–15 min |
| Login magic link | 5–10 min |
| Invite (first-time signup) | 24–72 h |
| Re-verify email | 1–24 h |
Short TTLs reduce the exploit window for an intercepted email. Avoid sending two pending tokens to the same user — the older one should be revoked when the new one is issued (store.revoke on the previous token), or rely on the user clicking the most recent.
Same-tenant defense in depth
For multi-tenant apps, double-check that the userId decoded from the consumed state belongs to the tenant in the current request context:
const state = await store.consume(token);
if (!state || state.kind !== "magic.recovery") throw new HttpError(401);
if (state.claims?.tenantId !== currentTenantId) throw new HttpError(401);@aooth/auth-moost does this for you via the RecoveryWorkflow guard chain. Rolling your own — remember to check it explicitly.
What @aooth/auth-moost adds on top
If you're using moost, the RecoveryWorkflow wraps these primitives with:
- HTTP route bindings (
POST /auth/recovery/start,POST /auth/recovery/finish). - A multi-step workflow envelope (
WfFinishedshape, countdown auto-redirect). - Rate limiting and CAPTCHA hooks.
- A pluggable
RecoveryConfigfor TTL, email kind, and auto-login behavior. - Audit-log entries at every step.
- Username vs. email vs. phone identifier resolution.
- Optional MFA challenge interleaving.
If your needs match the workflow's shape, use it. If they don't, the primitives on this page are what's underneath — and they're stable.
See also
- Magic Links — token + URL + storage primitives.
- Refresh — the same-ms
>=epoch gate. - Delivery —
EmailSendercontract for the recovery email. - Moost — Workflows — the
RecoveryWorkflowshipped on top of these primitives. - User — Password Hashing —
changePasswordand policy validation.