> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Email verification

> 6-digit code flow to verify an authenticated user's email — same primitive as magic-code sign-in.

After a user signs up (via password, OAuth-with-unverified-email, etc.), you usually want to confirm they own the email address. Pylon's email-verification flow is the same primitive as [magic-code sign-in](/auth/magic-codes), gated on an authenticated session — it sends a 6-digit code to the user's `email` field and stamps `emailVerified` on successful verify.

The OAuth + SSO sign-in paths automatically stamp `emailVerified` because the upstream IdP just vouched for the email. Use this flow when the email wasn't IdP-verified — manual signup, edit-email flow, etc.

## Endpoints

| Endpoint                            | Method | Auth    | Purpose                                          |
| ----------------------------------- | ------ | ------- | ------------------------------------------------ |
| `/api/auth/email/send-verification` | POST   | session | Email a 6-digit code to the user's current email |
| `/api/auth/email/verify`            | POST   | session | Submit the code; stamp `emailVerified`           |

## Schema

The User entity needs an `emailVerified` field. Pylon writes the current ISO 8601 timestamp on success:

```typescript theme={null}
import { entity, field } from "@pylonsync/sdk";

export const User = entity("User", {
  email: field.string().unique(),
  emailVerified: field.string().optional(),  // ISO 8601 — null = not verified
  // ...
});
```

If your schema lacks this field, `update` will reject the unknown column and `/email/verify` returns a 4xx with the storage layer's error code instead of silently succeeding.

## Sending a verification code

```bash theme={null}
curl -X POST https://your-app/api/auth/email/send-verification \
  -H 'Authorization: Bearer pylon_token'
```

Pylon looks up the caller's User row, reads `email`, mints a 6-digit code, and sends an email with subject `"Verify your email address"` via the configured email transport. Body: `"Your email verification code is: <code>\n\nThis code will expire in 10 minutes."`.

Response in production:

```json theme={null}
{ "sent": true, "email": "alice@acme.com" }
```

Response in dev (`PYLON_DEV_MODE=true`):

```json theme={null}
{ "sent": true, "email": "alice@acme.com", "dev_code": "123456" }
```

Errors:

| Status | Code                | Reason                                              |
| ------ | ------------------- | --------------------------------------------------- |
| 401    | `UNAUTHORIZED`      | No session                                          |
| 404    | `USER_NOT_FOUND`    | Session resolves to a user\_id with no matching row |
| 400    | `MISSING_EMAIL`     | User row has no `email` field                       |
| 429    | `RATE_LIMITED`      | A code was requested within the throttle window     |
| 500    | `EMAIL_SEND_FAILED` | Email transport returned an error                   |

The throttle is shared with magic-code sends — 1 code per email per minute. The 10-minute TTL is shared too.

## Verifying

```bash theme={null}
curl -X POST https://your-app/api/auth/email/verify \
  -H 'Authorization: Bearer pylon_token' \
  -H 'Content-Type: application/json' \
  -d '{"code": "123456"}'
```

Response on success:

```json theme={null}
{ "verified": true }
```

Pylon stamps `emailVerified` to the current ISO 8601 timestamp (e.g. `"2026-01-15T10:30:00Z"`).

Errors:

| Status | Code           | Reason                         |
| ------ | -------------- | ------------------------------ |
| 401    | `UNAUTHORIZED` | No session                     |
| 400    | `MISSING_CODE` | `code` field absent            |
| 400    | `INVALID_JSON` | Body wasn't JSON               |
| 4xx    | `INVALID_CODE` | Wrong code, expired, or burned |

## Gating handlers on `emailVerified`

In your TS code:

```typescript theme={null}
import { action } from "@pylonsync/functions";

export default action({
  async handler(ctx, args) {
    if (!ctx.auth.userId) throw new Error("sign in");
    const user = await ctx.db.get("User", ctx.auth.userId);
    if (!user?.emailVerified) {
      throw ctx.error("EMAIL_NOT_VERIFIED", "Verify your email before posting");
    }
    // proceed
  },
});
```

In policies, project `emailVerified` from the User entity and reference it directly:

```typescript theme={null}
import { policy } from "@pylonsync/sdk";

export const postsPolicy = policy({
  entity: "Post",
  // Pull the related user's emailVerified through the relation.
  allowInsert: "auth.userId == data.authorId",
  allowRead: "true",
});
```

Pylon doesn't bake an opinionated `auth.emailVerified` into the policy DSL today — the field lives on your User row and you reference it via your own handlers / queries.

## Security guarantees

* **Code generation, comparison, throttling shared with [magic-codes](/auth/magic-codes)** — 6-digit numeric, 10-minute TTL, burn-after-5-wrong-attempts, constant-time comparison.
* **Session-gated** — only the authenticated user can request + verify their own email. There's no admin override here; if you need to mark a verified-via-out-of-band-process, write the User row directly with admin auth.
* **ISO 8601 timestamp format** — `pylon_kernel::util::now_iso()` produces `2026-01-15T10:30:00Z`. The previous `<unix>Z` format was silently rejected by the storage adapter, leaving users in a perpetual unverified state. Fixed in framework — flag emails are verified iff `emailVerified` is non-null.

## Where to go next

* **[Magic codes](/auth/magic-codes)** — the same 6-digit code primitive as a primary sign-in flow
* **[Password](/auth/password)** — the register flow that produces an unverified email
