Verify Contact step (verify_contact)¶
The verify_contact step proves that the recipient of a workflow message
actually controls the contact endpoint (today: an email address) before the
run continues. It's purpose-built for the onboarding-an-unmanaged-user
scenario where the workflow has nothing to anchor identity to except the
address an upstream system handed in.
Status: feature-flagged. Set
FEATURE_VERIFY_CONTACT=trueto enable on a deployment. Defaults to on in dev/test and off in production.
When to use it¶
Drop a verify_contact step in front of any workflow action that depends
on the recipient really being the person at that address — typically:
- An onboarding flow that hands an unmanaged collaborator a portal link before issuing them seats, accounts, or sensitive information.
- A flow that creates external user records keyed off an address sent in via the API.
- Any "are you sure this is your address?" confirmation step that predates a credential being created.
For internal users with a session, prefer the consent step
(requireStepUpAuth: true if you need MFA freshness) — those users have
already proven control of an account.
Two modes¶
Both modes start by sending the recipient an email containing a high-entropy verification link. The mode controls what happens after the link is clicked.
mode: "link" (default)¶
Clicking the link confirms the address and immediately resumes the workflow. The simplest UX, suitable when the only attacker model worth defending against is "wrong address typed at intake."
mode: "link_and_code"¶
Clicking the link opens the public /verify page in portal-web, which
issues a numeric one-time code and sends it in a second email. The
recipient retypes the code on the page to complete verification.
Defends against:
- Pre-fetch link scanners in corporate mail gateways (the link click alone doesn't accept anything; only the code does).
- Forwarded link emails (the code goes to the original inbox; the forwarded recipient never sees it).
- Shoulder-surfed link URLs (the code is short-lived and one-shot).
Choose link_and_code for any flow that grants meaningful access (role
provisioning, document submissions, financial actions). Choose link
for low-stakes confirmations like preference reminders.
Step config¶
{
"id": "verify-owner",
"name": "Verify owner email",
"type": "verify_contact",
"config": {
"recipientEmail": "{{ownerEmail}}",
"mode": "link_and_code",
"linkExpiresInHours": 24,
"otpLength": 6,
"otpExpiresInMinutes": 10,
"otpMaxAttempts": 5,
"outputVariable": "ownerVerified",
"customSubject": "Confirm your email to continue",
"messageTemplate": "<p>Tap the button to verify <strong>{{recipientEmail}}</strong>.</p>",
},
"transitions": [
{ "on": "success", "goto": "create-account" },
{ "on": "expired", "goto": "notify-author-expired" },
{ "on": "exhausted", "goto": "notify-author-too-many-tries" },
],
}
| Field | Default | Notes |
|---|---|---|
recipientEmail |
required | Literal address (alice@example.com) or {{var}} interpolation. |
mode |
"link" |
"link" | "link_and_code". |
deliveryChannel |
"email" |
Reserved for future channels (SMS, Slack — tracked as a follow-up). |
linkExpiresInHours |
24 |
Bounded 1..168. |
otpLength |
6 |
Bounded 4..10. Only used in link_and_code mode. |
otpExpiresInMinutes |
10 |
Bounded 1..60. |
otpMaxAttempts |
5 |
Bounded 1..10. After this many wrong codes the row is exhausted. |
outputVariable |
"verifiedContact" |
Where the success result lands in the run's variable bag. |
customSubject |
"Please verify your email address" |
Subject of the verification email. |
messageTemplate |
built-in HTML | Handlebars template; gets {{acceptUrl}}, {{expiresAt}}, {{recipientEmail}}. |
Lifecycle and outcomes¶
The step pauses at status waiting_verification after dispatching the
verification email. From there:
| Outcome | Triggered by | Edge | Variable written |
|---|---|---|---|
| Success | Recipient completes verification | success |
outputVariable ← { verified: true, verifiedEmail, verifiedAt, mode } |
| Expired | Link or code TTL elapsed before completion | expired (falls through to error) |
none |
| Exhausted | otpMaxAttempts wrong codes submitted |
exhausted (falls through to error) |
none |
| Engine error | Dispatcher failure, unexpected DB error | error (falls through to global onError) |
none |
If you want a specific recovery path (re-send a fresh email, route the
run to an admin task), wire an expired and/or exhausted transition;
otherwise the step folds into the workflow's global onError policy.
Security model (what we protect, what we don't)¶
- Codes are never stored in the clear. The OTP service generates the
code, hashes it with HMAC-SHA256 (peppered with a value derived from
JWT_SECRET), persists only the hash, and hands the cleartext only to the dispatcher long enough to put it in the email body. - Verification uses constant-time hash equality to avoid timing attacks on the comparison.
- Resends invalidate prior un-redeemed codes. Issuing a fresh code
marks any older
pendingcode rows for the same parent link assuperseded, so a forwarded earlier email becomes inert as soon as the recipient asks for a new code. - Resends are rate-limited. A 30-second per-token cooldown is
enforced both server-side (cluster-wide limiter on the route) and as
a client-side timer on
/verify. The server-side limiter uses an atomicSET cooldown:resend:<invitationId> 1 EX 30 NXagainst the shared Redis cluster, so two replicas observing the same invitation collapse on the same window — a recipient cannot bypass the cooldown by hitting different replicas in succession.Deployment posture. When Redis is wired (the default production deployment) the cooldown is cluster-wide and no sticky routing is required. If the bootstrap factory cannot find a Redis client it falls back to an in-process limiter and emits a structured
VERIFY_CONTACT_LIMITER_LOCAL_ONLYwarn — operators should grep on this code in dashboards. While the fallback is active a multi-replica deployment requires either a single API replica or sticky sessions keyed on/api/verify-contact/:tokenso the same recipient hits the same replica for the cooldown window.The Redis path fails closed on transport errors: a Redis outage surfaces as
status: cooldownrather than letting the caller bypass the limiter. The recipient sees a normal "wait and retry" message; ops sees the structured warn and can correlate with the Redis incident. - The route layer is unauthenticated. The high-entropy link token is
the only credential — there's no
Authorizationheader — because the recipient is by definition unmanaged. Treat the link like a one-shot password reset URL. - Logging never sees the cleartext code. Never log
code, OTP body payloads, or the contents ofdispatcher.deliver()arguments. Audit events redact the code field by construction.
Author conventions¶
- Wire explicit
expiredandexhaustedtransitions whenever you want a custom recovery path (notify the author, escalate, retry with a fresh email). Without them the engine still resumes after the TTL elapse or attempt exhaustion, falling through to the workflow'serroredge or its globalonErrorpolicy — so the run won't hang inwaiting_verification, but the recipient also won't see anything more helpful than the default failure handling. - Prefer
link_and_codefor any step that immediately precedes role provisioning, secret distribution, or financial actions. - The recipient email is recorded on success; you can reference it via
{{ownerVerified.verifiedEmail}}in downstream steps. - The output variable's
verifiedAtis an ISO-8601 timestamp; comparing it againstfloh.now()in atransformstep lets you express freshness ceilings ("require verification within the last 5 minutes before granting privileged access").
Future enhancements¶
- Multi-channel delivery (SMS, Slack, generic connector).
OtpDeliveryDispatcheris already an interface; today onlyEmailOtpDispatcheris registered. Adding new channels means registering an additional dispatcher and exposing the channel choice in the designer. Tracked as a follow-up GitHub issue / Jira story. - Voice / IVR fallback for accessibility.
- Per-org policy defaults so a tenant can globally require
link_and_codefor verify_contact steps regardless of step config.