Require a Verified Email to Sign Up
With the policy on, verification becomes a separate server-side action: confirming a code records a single-use proof, and registration only checks whether this request carries it. Page code has no code argument and no "already verified" boolean.
The site's registration endpoint does not validate the email address: like name, it is
an optional display field. So writing "send a code, verify it, then register" in page code puts the whole sequence
under the browser's control — skipping the first two calls takes no skill at all. For "verified" to
actually gate registration, the server has to check it in the same call that creates the account.
The platform makes verification a separate server-side action: once a code checks out, the server records a single-use proof and the browser carries only an opaque ticket in an httpOnly cookie. Registration then checks whether this request carries the proof the project's policy requires. There is no verification-code argument on register.
Turn the policy on first
In the editor under Auth → Settings & OAuth, turn on "Require a verified email to sign up". It needs a working email integration (see Send Email and Verification Codes with Integrations), otherwise turning it on would close registration entirely — the platform refuses and points at the integrations panel.
Projects that leave it off behave exactly as before: no code is required and
user.email_verified_atstaysnull. Do not read that field as "an email was filled in".
Page code: three steps
Requires the talizen client 0.2.35 or later.
import { useAuth, startVerification, confirmVerification } from 'talizen/auth'
const { register } = useAuth()
// (1) send the code
await startVerification({ channel: 'email', to: email, purpose: 'register' })
// (2) confirm it: the proof is stored server-side; the browser only gets an
// unforgeable ticket in an httpOnly cookie
await confirmVerification({ channel: 'email', to: email, purpose: 'register', code })
// (3) register: no code argument, and no ticket to pass — the browser carries it
// automatically and the server checks it against the project policy
await register({ account: email, email, password })
You cannot read the ticket and do not need to. There is no "already verified" boolean in page code, so there is nothing to forget to check.
Four rules a proof follows
| Rule | Why |
|---|---|
Bound to the recipient: the email you register with must match the proven address exactly | Otherwise an attacker proves their own address and registers with someone else's |
Bound to the purpose: proofs are not interchangeable across purpose | A code requested for "subscribe to the newsletter" should not complete a registration |
| Single use: consumed when registration succeeds | One verification should not complete two things |
| Expires in 10 minutes | Long enough to type a password, short enough that a captured ticket is useless |
channel currently supports email only; passing sms errors explicitly rather than
quietly sending an email instead.
Your own rules: invite codes, domain allowlists
Email verification is enforced by the platform, but rules like invite codes, a @company.com
allowlist, or signup credit are yours. In page code they are only advisory — an attacker calls
the registration endpoint directly. To make them hold, turn on "Disallow sign-up from page code" in the
same panel and move registration into Func:
import type { TalizenFuncContext } from 'talizen/func-runtime'
export async function complete(input, ctx: TalizenFuncContext) {
const invite = ctx.db.get('invites', input.invite)
if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }
// Again no code / proof argument: the platform reads the proof from this
// request, and Func never touches the ticket.
const user = ctx.auth.register({
account: input.email,
email: input.email,
password: input.password,
profile: { invited_by: invite.owner },
})
ctx.db.update('invites', invite.id, { used: true, used_by: user.id })
return { ok: true }
}
With that on, calling register from page code returns 403 and registration must go through your Func. Sending and
confirming codes inside Func is ctx.verify.start() and ctx.verify.confirm(). The session cookie
is issued by the platform after registration: Func cannot mint a session or set a password directly.
How this differs from ctx.email.sendCode
Both look like "sending a verification code". The difference is who records the outcome:
| For | Outcome | |
|---|---|---|
ctx.email.sendCode / verifyCode | Your own flows: order confirmation, unsubscribe confirmation | Returned to your code only; the platform records nothing |
startVerification / ctx.verify | Proof of a contact | Recorded by the platform and consumed by registration |
So do not gate registration with
ctx.email.verifyCode(): thetrueit returns is just a value, and the server will not treat the address as proven because of it.
After registration
A verified signup sets user.email_verified_at. To limit what unverified users can do, check it inside
Func — do not gate login on it, which would lock out every existing user:
export async function createPost(input, ctx: TalizenFuncContext) {
const user = ctx.auth.requireUser()
if (!user.email_verified_at) return { ok: false, reason: 'verify_email_first' }
// ...
}
Addresses that already have an account
When a visitor asks for a code with an address that already has an account, the default is: the endpoint returns exactly the same result as any other request, but that address receives an "you already have an account, sign in instead" email rather than a code.
That is deliberate. Once verification is required, register is no longer an enumeration oracle — you
cannot reach the duplicate check without owning the address — which makes the code endpoint the
only remaining probe. If it answered differently for taken addresses, anyone could use it to discover
which emails have accounts on your site. An attacker never sees that email, so nothing is leaked; a real user still
learns what happened instead of receiving a code that cannot work.
So do not add your own "is this email taken" endpoint for a pre-flight check in page code — that is precisely the probe the default prevents.
Sites where enumeration does not matter (internal tools, B2B back offices) can turn on
"Tell visitors when an email is already registered" under Auth → Settings. Then
startVerification returns 409 directly: better UX and no wasted send.
Errors and edges
| Situation | Behaviour |
|---|---|
| Registering without verifying, or an expired ticket | 403 asking you to complete verification first |
| The registered address differs from the proven one | 403 stating the mismatch |
| Wrong code, expired code, no code was ever sent | One 400 for all three — distinguishing them turns the endpoint into a probe |
| A recipient asking for codes repeatedly | 429, sharing the integration's rate limit and daily cap |
| Turning the policy on without an email integration | The save is refused and points at the integrations panel |
| Page code registering while "Disallow sign-up from page code" is on | 403 pointing at your own Func |
Code length, expiry and attempt caps come from the integration's configuration — see Send Email and Verification Codes with Integrations. For the rest of Func, see Build Backend Features with Func.
