Auth guides
07 · Verification + reset

Email verification + password reset.

Auth mints the codes. You deliver them through whatever channel makes sense — your own SMTP, an SMS provider, or our Mail integration. The consume side is public; the mint side is PAK-only by deliberate design.


1

Why mint endpoints are PAK-only

Public mint endpoints are an account-enumeration vector and a spam-forwarder vector. Auth puts the abuse-mitigation point in your code, not in our pipeline.

Two threats every “forgot password” form has to handle:

  • Enumeration. An attacker hits the form with email after email, looks at the response shape, and compiles a list of real accounts.
  • Spam forwarding. An attacker hits the form rapidly with the same email — or with thousands of emails they want to spam — and uses your sender to do it.

Defending against both requires per-customer logic: maybe you have a captcha, maybe a paid-tier check, maybe a rate limit per-IP. Auth doesn't know your customer's policy. So we don't expose the mint endpoints publicly — your backend mints with a PAK, applies your own abuse-mitigation, and forwards the code to the user.


2

Mint a verification code

Customer-backend route. PAK with auth.user.verify.create on the app URN. Returns the 6-digit code in the response body — you deliver it however you like.

POST /<app_slug>/v1/auth/request-verification
curl https://api.auth.productcraft.co/acme/v1/auth/request-verification \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "email": "ada@example.com" }'
# Match (and email is unverified)
{ "code": "384201", "expires_at": "2026-05-11T11:46:18Z" }

# No match — uniform shape, no enumeration
{}

10-minute TTL. Single-use. Auth stores only the hash of the code; the plaintext is returned exactly once. Body also accepts phone for SMS verification — Auth doesn't care about the channel, just the contact value.

Latest code wins. Minting invalidates any previously issued, unconsumed code for the same contact — including the one Auth auto-emails at signup when your Mail integration is configured. Calling request-verification right after signup is therefore safe: the user only ever holds one valid code, the one you delivered.


3

Deliver the code

Two paths. The fast one uses our Mail service; the flexible one uses your own SMTP.

Path A — Mail

If your workspace has Mail enabled and a verified sender domain, Auth can mint and dispatch in one call. Distinct PAK perm so customers can mint without opting into outbound Mail:

POST /<appSlug>/v1/auth/send-verification-email
curl https://api.auth.productcraft.co/acme/v1/auth/send-verification-email \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "email": "ada@example.com" }'
# Success
{ "expires_at": "2026-05-11T11:46:18Z" }

# Pre-condition errors (412)
{ "code": "ENVOI_NOT_ENABLED",            "message": "Mail notifications are not enabled for this app" }
{ "code": "ENVOI_SENDER_NOT_CONFIGURED",  "message": "Mail sender address is not configured for this app" }
{ "code": "ENVOI_TEMPLATE_NOT_CONFIGURED","message": "Mail verification template is not configured for this app" }
{ "code": "ENVOI_APP_NOT_BOUND_TO_WORKSPACE", "message": "App is not bound to a workspace; cannot route notifications through the Mail service" }

# Dispatch failure (503)
{ "code": "ENVOI_DISPATCH_FAILED",        "message": "Failed to dispatch through the Mail service: <reason>" }

The plaintext code is not in the response for this path. Mail delivers it to the customer directly. If you also want to log the code for your own records, use Path B instead.

Path B — Your own SMTP

Hit the bare request-verification endpoint (Step 2), get the code back, deliver it however you like. Useful when you have an existing transactional-email pipeline (SendGrid, Postmark, AWS SES) and prefer to keep templates there.

Your backend
async function sendVerification(email: string) {
  // 1. Mint the code through Auth
  const res = await fetch(`https://api.auth.productcraft.co/acme/v1/auth/request-verification`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.AUTH_PAK}`,
      'content-type': 'application/json',
    },
    body: JSON.stringify({ email }),
  }).then(r => r.json());

  if (!res.code) return; // No match — silent (uniform shape)

  // 2. Deliver however you like
  await yourEmailProvider.send({
    to: email,
    subject: 'Verify your email',
    template: 'verification',
    data: { code: res.code, expiresAt: res.expires_at },
  });
}

4

Consume the code

Public endpoint — anyone with the code can submit it; that's the point. Single-use; marking the contact verified is atomic.

POST /<appSlug>/v1/auth/verify
curl https://api.auth.productcraft.co/acme/v1/auth/verify \
  -H 'content-type: application/json' \
  -d '{ "code": "384201" }'
{
  "account_id":  "648616c8-...",
  "contact_id":  "9f2b4e10-...",
  "type":        "email",
  "value":       "ada@example.com",
  "verified_at": "2026-05-11T11:38:42Z"
}

Every failure is the same response: 410 Invalid or expired code — whether the code is wrong, expired, already consumed, or superseded by a newer one. Deliberately opaque: distinguishing the cases would hand an attacker an oracle.


5

Password reset — same shape

Mint with a PAK (auth.user.password-reset.create), deliver the code, consume on a public route. Same 10-minute TTL, same latest-code-wins rule — a new reset code invalidates any outstanding one for the account.

Mint
curl https://api.auth.productcraft.co/acme/v1/auth/request-password-reset \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "email": "ada@example.com" }'

# Or hit the Mail shortcut:
curl https://api.auth.productcraft.co/acme/v1/auth/send-password-reset-email \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "email": "ada@example.com" }'
Consume
curl https://api.auth.productcraft.co/acme/v1/auth/reset-password \
  -H 'content-type: application/json' \
  -d '{
    "code":         "718462",
    "new_password": "NewCorrectHorseBatteryStaple2"
  }'

On success: 204. The user's password is updated AND every active session for the account is revoked. Forces re-signin. Intentional — a password reset always implies a compromise; we don't leave existing access tokens in place.


6

Unverified-contact corner case

A contact must be verified before it can be used for password reset. Reset against an unverified contact is silently no-op.

Why: an attacker who registers an unverified email contact on a victim's account could push reset codes through it. The verified-only requirement breaks that path. Concretely: request-password-reset against { email: "unverified@example.com" } returns {} — same shape as the no-match case. Customer-product can't enumerate.

The fix in your product: when a user adds a new email contact, walk them through the verification flow before anything sensitive can use that email. The Auth/me/contacts/:id/promote route — promoting a verified contact to primary — explicitly refuses unverified contacts with 409.


7

Customer-side rate limiting

The shape is yours. Suggested floor:

  • Per email: 5 mint requests per hour. Counter increments even on no-match so timing doesn't leak existence.
  • Per IP: 10 mint requests per hour. Higher because legit users behind shared NAT (e.g. an office) can collide.
  • Captcha threshold: after 3 attempts in a 10-minute window, require a captcha solve before the next mint.

Auth already enforces a coarser per-IP rate limit on both the mint and consume endpoints (429 when blown), but the granular per-user policy lives in your backend.


8

Audit

Every mint + consume writes an audit row. Useful for incident response and for customer support.

Audit actions:

  • auth.contact_verification.requested + completed
  • auth.password_reset.requested + completed

Each row records the actor type (your PAK's m2m identity on mint; the end user on consume), the contact or account acted on, and the timestamp. Surface in your customer-support dashboard viaGET /v1/apps/:appId/audit-logs?action=auth.*.