Skip to content

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 ​

StepPrimitivePackage
1. TokengenerateMagicLinkToken()@aooth/auth
2. StorageCredentialStore.persist(state, ttl) + consume(token)@aooth/auth
3. DeliveryEmailSender.send({ kind: 'recovery.magicLink', ... })@aooth/auth (interface)
4. Verificationstore.consume(token) → users.changePassword → auth.revokeAllForUser → auth.issue@aooth/auth + @aooth/user

Step 1 — generate the token ​

ts
import { generateMagicLinkToken } from "@aooth/auth";

const token = generateMagicLinkToken(); // 43 chars, base64url, URL-safe

32 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:

ts
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:

  1. Persist with a distinct kind ('magic.recovery', 'magic.login', 'magic.invite', etc.) and check it explicitly after consume, OR
  2. Discriminate by metadata.label and have your auth guard reject any unknown label.
ts
// 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 kinds

Step 3 — send the email ​

ts
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 ​

ts
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-login

consume is atomic — retrieve and revoke in one operation. Two concurrent submissions of the same link cannot both succeed.

The full minimal flow ​

ts
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, {
    roles: ["user"], // typed payload field, flat
    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:

  1. consume(token) — kill the magic link.
  2. changePassword(userId) — update the credential.
  3. revokeAllForUser(userId) — bump the per-user revocation epoch (or delete every row). Every previously-issued access and refresh token is dead.
  4. issue(userId, …) — issue a fresh credential. Its iatMs is >= 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 caseTTL
Password reset link10–15 min
Login magic link5–10 min
Invite (first-time signup)24–72 h
Re-verify email1–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:

ts
const state = await store.consume(token);
if (!state || state.kind !== "magic.recovery") throw new HttpError(401);
if (state.tenantId !== currentTenantId) throw new HttpError(401); // typed payload field, flat

@aooth/auth-moost does this for you via the recovery flow's resolver chain. Rolling your own — remember to check it explicitly.

What @aooth/auth-moost adds on top ​

If you're using moost, the unified AuthWorkflow's recoveryFlow (wfid auth/recovery/flow) wraps these primitives with:

  • A single /auth/trigger entry point (no per-step routes — the flow is driven by the wf state token).
  • A multi-step WfFinished envelope (countdown auto-redirect, magic-link or OTP delivery).
  • recoveryStateTtlMs capping the request-to-reset window; per-request policy via resolveXxx(ctx) getters.
  • Anti-enumeration on the identifier step + optional known-factor verification before reset.
  • Post-reset session revocation + autoLoginOnRecover (auto-login vs fresh-login redirect).

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 ​

Released under the MIT License.