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
- 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.
talizenclient 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:
| File | How the page calls it |
|---|---|
/backend/func/auth/reset-password.ts | invoke('auth/reset-password.requestCode', …)invoke('auth/reset-password.confirm', …) |
/backend/func/auth/change-password.ts | invoke('auth/change-password', …) (no method name needed when the export is main) |
ctx.auth for the caller, ctx.users for anyone else
| What you want | Use |
|---|---|
| Read who is signed in | ctx.auth.currentUser() / ctx.auth.requireUser() |
| Look a user up by email / account / id | ctx.users.find({ email }), returns null when there is none |
| Check a user's current password | ctx.users.checkPassword({ userId, password }) |
| Change a user's password | ctx.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 this | Or else |
|---|---|
Call ctx.users.find before sending a code | Anyone 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 exists | Returning { 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 message | Same 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,
checkPasswordthrows 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 write | What already happens |
|---|---|
Codes from Math.random(), or storing codes in ctx.db / ctx.cache | sendCode 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" counter | A code is voided after 5 wrong guesses |
| Your own "how soon can they ask again" limit | At most 5 mails per recipient per 10 minutes, and 500 per project per day |
| Hashing passwords yourself, or storing them in a JSON table | setPassword uses bcrypt; plaintext never reaches the database or the logs |
| Clearing sessions yourself after a change | setPassword revokes all of that user's sessions (and you have no access to the session table anyway) |
| Your own failed-password counter and lockout | checkPassword 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
| Case | Behaviour |
|---|---|
find matches nobody | Returns null, does not throw |
| Wrong code, expired code, or no code ever sent | verifyCode returns false in all three cases, undistinguished |
| One recipient asking repeatedly | sendCode throws (rate limited) |
| No email integration on the project | sendCode throws and points at the integrations panel |
setPassword on an unknown user | Throws 404. Catch it; do not pass it to the browser |
| Account only signs in through a provider and has no password | setPassword throws 404, checkPassword returns false. The platform will not add a password sign-in to it |
| Disabled account | Throws 403. A new password would not let them in, so it fails outright |
| One address, several accounts | setPassword({ email }) throws 409 instead of changing "the first one". Use account or userId |
Too many checkPassword failures | Throws 429, locked for an hour, sharing the login endpoint's allowance |
| More than one of the three ref fields given | Throws 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.
