Heimdall guides
07 · Verification + reset

Email verification + password reset.

Heimdall mints the codes. You deliver them through whatever channel makes sense — your own SMTP, an SMS provider, or our Envoi 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. Heimdall 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. Heimdall 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 heimdall.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.heimdall.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": "5A1NOP", "expires_at": "2026-05-11T11:46:18Z" }

# No match — uniform shape, no enumeration
{}

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


3

Deliver the code

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

Path A — Envoi

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

POST /<appSlug>/v1/auth/send-verification-email
curl https://api.heimdall.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": "Workspace hasn't enabled notifications_via_envoi" }
{ "code": "ENVOI_SENDER_NOT_CONFIGURED",  "message": "Set a verified sender first" }
{ "code": "ENVOI_TEMPLATE_NOT_CONFIGURED","message": "Configure the verification email template" }
{ "code": "ENVOI_APP_NOT_BOUND_TO_WORKSPACE", "message": "App is missing workspace_id" }

# Dispatch failure (503)
{ "code": "ENVOI_DISPATCH_FAILED",        "message": "RabbitMQ publish failed" }

The plaintext code is not in the response for this path. Envoi 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 Heimdall
  const res = await fetch(`https://api.heimdall.productcraft.co/acme/v1/auth/request-verification`, {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.HEIMDALL_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.heimdall.productcraft.co/acme/v1/auth/verify \
  -H 'content-type: application/json' \
  -d '{ "token": "5A1NOP" }'
{
  "account_id":        "648616c8-...",
  "email_verified_at": "2026-05-11T11:38:42Z"
}

Failure modes — 400 Invalid token (unknown, consumed, or expired), 410 Token has expired. Heimdall doesn't distinguish “wrong code” from “unknown account” — both 400.


5

Password reset — same shape, different TTL

Mint with a PAK (heimdall.user.password-reset.create), deliver the code, consume on a public route. 10-minute TTL — shorter than verification because the consequence of a leaked reset code is higher.

Mint
curl https://api.heimdall.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 Envoi shortcut:
curl https://api.heimdall.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.heimdall.productcraft.co/acme/v1/auth/reset-password \
  -H 'content-type: application/json' \
  -d '{
    "token":       "Q6EH4T",
    "newPassword": "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 Heimdall/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.

Heimdall already enforces a coarser version of this on the verification-token throttle layer (and 429s on the mint endpoint when blown), but the granular 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.email_verification.requested + completed
  • auth.password_reset.requested + completed
  • auth.contact_verification.requested + completed

Each row carries the contact value (hashed for password reset; plain for email verification — by then it's the user's known email), the PAK id that minted it, and the IP. Surface in your customer-support dashboard viaGET /v1/apps/:appId/audit-logs?action=auth.*.