Skip to content

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=true to 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.

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."

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 pending code rows for the same parent link as superseded, 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 atomic SET cooldown:resend:<invitationId> 1 EX 30 NX against 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_ONLY warn — 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/:token so 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: cooldown rather 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 Authorization header — 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 of dispatcher.deliver() arguments. Audit events redact the code field by construction.

Author conventions

  • Wire explicit expired and exhausted transitions 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's error edge or its global onError policy — so the run won't hang in waiting_verification, but the recipient also won't see anything more helpful than the default failure handling.
  • Prefer link_and_code for 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 verifiedAt is an ISO-8601 timestamp; comparing it against floh.now() in a transform step 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). OtpDeliveryDispatcher is already an interface; today only EmailOtpDispatcher is 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_code for verify_contact steps regardless of step config.