AuthReset and Change Passwords

Reset and Change Passwords

Build "forgot password" and "change password" in Func: ctx.users.find decides whether to send a code, ctx.email.verifyCode checks it, ctx.users.setPassword changes it. Complete code for both flows, the three rules that fail silently if you skip them, and what you should not reimplement.

Building "forgot password" and "change password" on your site: Creght has no built-in password-reset endpoint, and no resetPassword() in the client SDK. You write these two methods in Func and call them from the page with invoke(). Password hashing, single-use codes and their rate limits, revoking old sessions, and brute-force lockout are handled by the platform — you write what makes the change allowed.

Before you start

  1. Connect an email integration to the project (editor: Backend → Integrations), see Send email and verification codes through an integration. Without it no code can be sent.
  2. talizen client 0.2.37 or later, which only affects type hints in the editor.

Unrelated to your registration settings: whatever you chose for open registration, required email verification, or Func-only registration, password reset works the same. Leave those alone.

Files and call names

Funcs are site source files. The path without its extension is the call key, and anything after . is the method name:

FileHow the page calls it
/backend/func/auth/reset-password.tsinvoke('auth/reset-password.requestCode', …)
invoke('auth/reset-password.confirm', …)
/backend/func/auth/change-password.tsinvoke('auth/change-password', …) (no method name needed when the export is main)

ctx.auth for the caller, ctx.users for anyone else

What you wantUse
Read who is signed inctx.auth.currentUser() / ctx.auth.requireUser()
Look a user up by email / account / idctx.users.find({ email }), returns null when there is none
Check a user's current passwordctx.users.checkPassword({ userId, password })
Change a user's passwordctx.users.setPassword({ email, password })

All three ctx.users methods can point at any user in the project, so pointing at the wrong one changes the wrong account. Give exactly one of email, account or userId; passing two is an error.

Flow 1: signed out, reset with an email code

Two requests: ask for a code, then trade the code for a new password.

// /backend/func/auth/reset-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'

const SCENE = 'reset_password'
const MIN_PASSWORD = 8

// Step 1: ask for a code
export function requestCode(input: { email?: string }, ctx: TalizenFuncContext) {
  const email = (input.email ?? '').trim()
  if (!email) throw new Error('email is required')

  // Check whether the account exists before deciding to send anything
  const user = ctx.users.find({ email })
  if (user) {
    try {
      ctx.email.sendCode({ to: email, scene: SCENE })
    } catch (err) {
      // Send failures (rate limit, integration trouble) must not reach the
      // browser either. Log them and read the logs yourself
      console.error('send reset code failed', String(err))
    }
  }

  // Same result whether or not the account exists
  return { ok: true }
}

// Step 2: verify the code and set the password, in one request
export function confirm(
  input: { email?: string; code?: string; password?: string },
  ctx: TalizenFuncContext,
) {
  const email = (input.email ?? '').trim()
  const code = (input.code ?? '').trim()
  const password = input.password ?? ''

  if (!email || !code) throw new Error('email and code are required')
  if (password.length < MIN_PASSWORD) throw new Error('password must be at least 8 characters')

  // Check existence before verifying: a matching code is consumed, so verifying
  // against a non-existent address burns one of a real user's attempts
  const user = ctx.users.find({ email })
  if (!user) throw new Error('the code is wrong or has expired')

  if (!ctx.email.verifyCode({ to: email, scene: SCENE, code }))
    throw new Error('the code is wrong or has expired')

  // Only verified requests reach this line. setPassword takes no code: the lines
  // above are what decides whether the change is allowed
  ctx.users.setPassword({ email, password })
  return { ok: true }
}

scene keeps codes for different purposes apart. It accepts letters, digits, underscore and dash, 1–32 characters. A code the user received for signing in cannot be spent on a password reset.

Three rules you have to follow

None of these three raises an error. Skip one and the site still works, but you have a real problem:

Do thisOr else
Call ctx.users.find before sending a codeAnyone can use your "forgot password" form to make your domain mail arbitrary addresses — your money, and your sending reputation
Return the same result whether or not the account existsReturning { found: false } hands out a lookup endpoint: anyone can probe which addresses are registered on your site
Catch setPassword's errors and replace them with one fixed messageSame as above: a 404 "user not found" reaching the browser gives away exactly the same thing

The page has to cooperate: show one fixed message regardless of what the call returns — something like "if that address has an account, we sent a verification code" — and never branch on the result.

Flow 2: signed in, change the password

Confirm the current password with ctx.users.checkPassword, then change it:

// /backend/func/auth/change-password.ts
import type { TalizenFuncContext } from 'talizen/func-runtime'

export function main(
  input: { oldPassword: string; password: string },
  ctx: TalizenFuncContext,
) {
  const user = ctx.auth.requireUser()
  if (input.password.length < 8) throw new Error('password must be at least 8 characters')

  if (!ctx.users.checkPassword({ userId: user.id, password: input.oldPassword }))
    throw new Error('current password is incorrect')

  ctx.users.setPassword({ userId: user.id, password: input.password })
  return { ok: true }
}

checkPassword returns a boolean: a wrong password gives false rather than an error. It also returns false for accounts created through Google or another provider that never set a password — for those, use the email-code flow above instead, sending to ctx.auth.requireUser().email.

After too many wrong attempts in a row, checkPassword throws 429 and locks for an hour. That allowance is shared with the login endpoint, so somebody who just failed a few sign-ins gets locked sooner here. Give 429 its own message ("too many attempts, try again later") instead of folding it into "current password is incorrect", or the user will keep retrying until fully locked out.

Page code

import { useState } from 'react'
import { invoke } from 'talizen/func'
import { useAuth } from 'talizen/auth'

export function ForgotPasswordForm() {
  const [step, setStep] = useState<'email' | 'code'>('email')
  const [email, setEmail] = useState('')
  const [code, setCode] = useState('')
  const [password, setPassword] = useState('')
  const [hint, setHint] = useState('')

  async function requestCode() {
    await invoke('auth/reset-password.requestCode', { email })
    // Fixed text: never infer from the result whether this address is registered
    setHint('If that address has an account, we sent a verification code')
    setStep('code')
  }

  async function confirm() {
    try {
      await invoke('auth/reset-password.confirm', { email, code, password })
      window.location.href = '/login'
    } catch (err) {
      setHint(err instanceof Error ? err.message : 'Reset failed, please try again')
    }
  }

  // ...form markup omitted
}

// Changing the password while signed in
async function changePassword(oldPassword: string, password: string) {
  await invoke('auth/change-password', { oldPassword, password })
  await useAuth().logout()          // the session is already void server-side
  window.location.href = '/login'
}

Do not reimplement these

The platform already guarantees the following; rewriting any of it in Func only makes it weaker:

Do not writeWhat already happens
Codes from Math.random(), or storing codes in ctx.db / ctx.cachesendCode issues a 6-digit code valid for 10 minutes, consumed on match, compared in constant time (length and expiry are integration settings)
Your own "wrong code" counterA code is voided after 5 wrong guesses
Your own "how soon can they ask again" limitAt most 5 mails per recipient per 10 minutes, and 500 per project per day
Hashing passwords yourself, or storing them in a JSON tablesetPassword uses bcrypt; plaintext never reaches the database or the logs
Clearing sessions yourself after a changesetPassword revokes all of that user's sessions (and you have no access to the session table anyway)
Your own failed-password counter and lockoutcheckPassword shares the login endpoint's allowance: 5 failures, locked for an hour

Three things are genuinely yours: password length and strength rules (the platform only rejects an empty password), what makes the change allowed (a code, the old password, or your own risk checks), and the wording and redirects.

Error reference

CaseBehaviour
find matches nobodyReturns null, does not throw
Wrong code, expired code, or no code ever sentverifyCode returns false in all three cases, undistinguished
One recipient asking repeatedlysendCode throws (rate limited)
No email integration on the projectsendCode throws and points at the integrations panel
setPassword on an unknown userThrows 404. Catch it; do not pass it to the browser
Account only signs in through a provider and has no passwordsetPassword throws 404, checkPassword returns false. The platform will not add a password sign-in to it
Disabled accountThrows 403. A new password would not let them in, so it fails outright
One address, several accountssetPassword({ email }) throws 409 instead of changing "the first one". Use account or userId
Too many checkPassword failuresThrows 429, locked for an hour, sharing the login endpoint's allowance
More than one of the three ref fields givenThrows 400. Pass exactly one of email, account, userId

Testing it

No need to publish the site first. Run the method with sample input from the Func editor, or from the CLI:

echo '{"email":"you@example.com"}' > input.json

creght func run --site_id=<project_id>/<site_id> \
  --key=auth/reset-password.requestCode \
  --input=input.json

Run requestCode once with an address that really has an account and confirm the code arrives, then once with an address that does not: the response must be byte-for-byte the same and no mail should arrive. That is the second rule above, verified.

See also: Require a Verified Email to Sign Up, Send email and verification codes through an integration, Build a site backend with Func.

Render diagnostics