# Require a Verified Email to Sign Up | Talizen | Talizen

> Prove mailbox ownership with startVerification / confirmVerification; the platform enforces it on registration per project policy, and your own rules move into Func.

[Talizen API](/)

[EN](/api/auth-verified-registration.md)/ [中文](/api/auth-verified-registration.md?lang=zh)

### Overview

- [Talizen API for AI](/api.md)

### Discoverability

- [How to optimize llms.txt](/api/optimize-llms-txt.md)

### Site configuration

- [Configure talizen.config.ts](/api/talizen-config.md)
- [Implement domain-based locale routing](/api/domain-locale-routing.md)

### Backend

- [Calling external APIs on the server and managing the cache](/api/ssr-external-api-cache.md)
- [Build site backend workflows with Func](/api/func-backend.md)
- [JSON tables: definition, reads, and queries](/api/func-json-tables.md)
- [Uploads: signed direct upload and Func-generated files](/api/func-assets-upload.md)
- [Timeouts and streaming responses](/api/func-timeout-streaming.md)
- [Integrate Alipay PC Web Payment with Func](/api/func-alipay-payment.md)

### Auth

- [Require a Verified Email to Sign Up](/api/auth-verified-registration.md)
- [Reset and Change Passwords](/api/auth-password-reset.md)
- [Sign In From a Func](/api/auth-func-login.md)
- [Query users from a Func](/api/func-user-directory.md)

### Integrations

- [Send Email and Verification Codes with Integrations](/api/func-email-integration.md)
- [Take Alipay payments with an integration](/api/func-alipay-integration.md)

### Help center

[Talizen Help](/docs/start.md)

Product guides for building sites in the editor.

AuthRequire a Verified Email to Sign Up

# 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](/api/func-email-integration.md)), 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. This switch
> governs **registration started from page code** only; it disappears when the sign-up mode is "Backend
> function only", because verification on that path lives entirely in your Func.

## Page code: three steps

Requires the `talizen` client 0.2.35 or later.

```typescript
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

Rules like **invite codes, a `@company.com` allowlist, or signup credit** are yours, not the
platform's. In page code they are only **advisory** — an attacker calls the registration endpoint directly.
To make them hold, set the sign-up mode to **"Backend function only"** and move registration into Func.

In that mode, calling register — or the send-code endpoint — from page code returns 403: registration only goes
through your Func. **Verification is then performed entirely by your code and the platform checks nothing**,
which is why the "Require a verified email" switch disappears from the panel: there is nothing to configure. Return
early when the code does not match, and the order of your code is the guarantee:

```typescript
import type { TalizenFuncContext } from 'talizen/func-runtime'

export function sendCode(input, ctx: TalizenFuncContext) {
  const invite = ctx.db.get('invites', input.invite)
  if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }

  ctx.verify.start({ channel: 'email', to: input.email, purpose: 'register' })
  return { ok: true }
}

export function complete(input, ctx: TalizenFuncContext) {
  // A separate request: passing sendCode does not vouch for this one.
  const invite = ctx.db.get('invites', input.invite)
  if (!invite || invite.used) return { ok: false, reason: 'bad_invite' }

  // Confirm first. In Func, confirm returns only a boolean — no ticket, no cookie.
  if (!ctx.verify.confirm({
    channel: 'email',
    to: input.email,
    purpose: 'register',
    code: input.code,
  })) return { ok: false, reason: 'bad_code' }

  // Reaching this line is what "verified" means. register takes no code,
  // no ticket, and no already-verified flag.
  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 }
}
```

The session cookie is still issued by the platform after registration: **Func cannot mint a**
**session**. Setting a password is a different matter and Func can do it — see
[Reset and Change Passwords](/api/auth-password-reset.md).

**The order is the guarantee**

The platform does not check whether this
path verified anything, so "create the account now, verify later" raises no error — it just creates the account. Put
`confirm` before `register` and return early when it fails.

## 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 |

> On the page-code path, a `true` from `ctx.email.verifyCode()` gates nothing —
> registration consumes the proof the platform recorded. When registration is routed through Func either one can gate
> (the platform checks neither), but prefer `ctx.verify` for registration: only it also checks whether the
> address is already taken.

## After registration

The platform does **not** keep a "this user verified their email" marker — there is no
`email_verified_at` field, and you should not add one to imitate it. Verification is a step during
registration, not a state flag on the user.

Such a marker carries no usable information: on a project that requires verification every account has it, and on one
that does not, no account has it. It only separates old from new accounts when a project turns verification on midway —
and the right response there is to run existing users through verification once, not to read a flag and restrict
them.

When you genuinely need per-user permissions, keep it as business state in your own table (for example
`members.status`), written and checked by your Func. Then its meaning is yours, and nobody mistakes it for a
platform guarantee.

## 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](/api/func-email-integration.md). For the rest of Func, see
[Build Backend Features with Func](/api/func-backend.md).

[Previous\
\
Take Alipay payments with an integration](/api/func-alipay-integration.md) [Next\
\
Reset and Change Passwords](/api/auth-password-reset.md)

On this page

- [Turn the policy on first](#switch)
- [Page code: three steps](#page)
- [Four rules a proof follows](#rules)
- [Your own rules: invite codes, domain allowlists](#func)
- [How this differs from ctx.email.sendCode](#boundary)
- [After registration](#after)
- [Addresses that already have an account](#taken)
- [Errors and edges](#errors)

Looking for the help center?

[Talizen Help](/docs/start.md)

> 全站页面清单：[/llms.txt](/llms.txt)
