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.
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, 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:
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
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:
{ "sent": true, "email": "alice@acme.com" }
Response in dev (PYLON_DEV_MODE=true):
{ "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
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:
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:
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:
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 — 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 — the same 6-digit code primitive as a primary sign-in flow
- Password — the register flow that produces an unverified email