OTP Verification in Forms
Overview
Some forms need to confirm that the person filling them in really owns the phone number or email address they entered — most commonly forms tied to a membership database. When an admin enables the OTP setting under profile mapping in the CMS, the client-side form inserts a six-digit code verification step between pressing submit and actually sending the answers.
The real enforcement lives on the server: a submission is only accepted when it carries an OTP reference that has been successfully verified.
Business Flow
- The user completes the form and presses submit.
- The container checks whether this form requires OTP verification. If it does and no reference exists yet, the answers are held in memory and the screen switches to the OTP verification step. Holding the answers matters: it guarantees that member matching at request time and at submission time uses exactly the same set of values.
- The verification screen inspects which channels the admin enabled. If more than one is available, the user first chooses between SMS and email.
- Requesting a code calls
POST /form-builder/:hash/otp/requestwith the form id, the held answers, and the chosen channel. The response returns a reference, the channel actually used, a masked destination, and an expiry duration. - The screen renders the copy the admin configured, substituting the
`{destination}`placeholder with the partially masked destination, for example08xxxxx051. - The user enters the six-digit code, and the app calls
POST /form-builder/:hash/otp/verifywith the reference and the entered code. - On success, the container stores the reference and immediately submits the held answers, attaching the reference into the FormData payload.
- The resend button enforces a 60-second cooldown with a visible per-second countdown.
Error handling
| Case | Behaviour |
|---|---|
| Too many incorrect attempts, or an expired code | The user must request a new code before they can enter one again |
| No member profile matches the submitted data | The user is sent back to the form with the message rendered there, since this is a data-entry problem rather than a verification problem |
Key Screens & Components
OTP verification screen
- The main screen (
src/components/form-builder/otp/otp-page.tsx) holds the entire state machine in one place: requesting a code, verifying it, the resend countdown, and the channel picker. Its key constants are a six-digit code length and a 60-second resend cooldown.
Supporting modules (all under src/components/form-builder/otp/)
- The helper module (
otp-util.ts) resolves which channels are enabled, decides whether OTP is required for a given form, and turns channel names into user-facing labels. - The copy module (
otp-copy.ts) holds the default message text and the placeholder substitution function. This default copy must match the CMS side character for character, so that what admins preview is exactly what users see. - The error parsing module (
otp-error.ts) reads error shapes that do not follow the system's standard envelope, and handles cases where the error code is nested inside the message.
Service and types
- The OTP service (
src/service/otp.service.ts) exposes the request and verify calls. - Types covering the OTP configuration, channels, and copy live in
src/service/types/form-builder.types.ts.
Endpoints used
| Method | Path | Purpose |
|---|---|---|
| POST | /form-builder/:hash/otp/request | Request an OTP code |
| POST | /form-builder/:hash/otp/verify | Verify an OTP code |
Unit tests cover the screen, the helper module, and the copy module under src/components/form-builder/otp/__tests__/.
Dependencies
- Both endpoints attach the
x-liff-tokenheader through the same helper used by form submission (see LINE Login via LIFF). - The screen uses the same theme CSS variables as the form, so the visual transition feels continuous rather than like leaving the form.
- Inseparably coupled to Form Filling, as it is a step inside the same container.
- System-level error messages come entirely from the server; the client does not translate or rewrite them.
Backend Details (Client API)
The core idea: the OTP goes to the contact on record, not the one typed in
This is the most important and most commonly misunderstood aspect of the feature. Once the respondent's answers match a row in the customer database, the code is sent to the phone number or email stored on that row — not to whatever the respondent typed. The intent is to prove the respondent owns the record, not merely that they know its contents.
The consequence for the web app: the (masked) destination shown to the user comes from the backend alone; the web app cannot derive it, and the user may well see a number they did not enter.
Requesting a code (POST /api/form-builder/:hash/otp/request)
The same endpoint serves both the first request and a resend, distinguished by whether a reference is supplied. The order of checks:
- Load the form → verify
x-liff-token→ enforce the login requirement. - A form without OTP enabled → 400
OTP is not enabled for this form. - The member match is re-run server-side from the answers in the body every single time — the client's result is never trusted. On no match the response is
PROFILE_NOT_FOUNDand no OTP is sent at all. (This is why the web app must freeze the answers and send the identical set both when requesting the OTP and when submitting — different answers mean a different match.) - The channel is chosen in order: the value in the body → the existing session's channel (on resend) → the sole enabled channel if the form has only one → otherwise 400
Please choose a verification channel.A channel not enabled on the form is likewise a 400. - The destination is read from the matched row's column — if that column is empty → 400
{code: "OTP_NO_CONTACT"}(the member exists but has no contact on file; an admin has to fill it in). - The OA's OTP configuration is loaded and the channel checked for completeness: SMS must be enabled with key and secret present; email must be enabled with a subject and body, and the body must contain the
{otp}placeholder. Anything missing → 400{code: "OTP_NOT_CONFIGURED"}, a configuration problem rather than a user error. - Resend guards (these apply to resends only, not the first request): less than 60 seconds since the previous send → 429
{code: "OTP_RESEND_COOLDOWN"}; three sends already made → 429{code: "OTP_RESEND_LIMIT"}. Note these are 429, unlike the other errors in this family. - The code is dispatched over the chosen channel and a session is written to Redis under a freshly generated reference.
- The reference, the channel actually used, the masked destination, and the expiry are returned with status 201.
Verifying a code (POST /api/form-builder/:hash/otp/verify)
- A missing or expired session →
{code: "OTP_EXPIRED"}. - An already-verified session → passes immediately without rechecking the code, so repeated calls do not consume attempts.
- Five wrong attempts → the session is deleted and
{code: "OTP_MAX_ATTEMPTS"}is returned; the user must start a whole new round. - A wrong attempt below the limit → 400
{code: "OTP_INVALID_PIN"}with a message that states how many attempts remain, so the web app can display the number without tracking it. - Transport failures (ThaiBulkSMS outage, network error) do not count as wrong attempts — users do not lose attempts to system problems.
- A successful verification sets the verified flag but deliberately does not extend the TTL, so nobody can stretch the verification window indefinitely. (A resend does reset the TTL, because there genuinely is a new code.)
- A session bound to a different form is rejected as
OTP_EXPIRED— defence in depth against reusing one form's OTP on another.
The submit gate — why a verified OTP can still yield OTP_REQUIRED
At submission time the backend checks all five of the following; failing any one produces the same 400 {code: "OTP_REQUIRED"}:
- A reference is attached to the body.
- That session still exists (not expired).
- The session is genuinely verified.
- The session belongs to this form.
- The row bound to the session equals the row matched again at submit time.
Point 5 is a real-world cause: if the user goes back and edits an answer used for member matching after verifying the OTP, the match resolves to a different row and the verified OTP instantly becomes invalid. It prevents verifying with one set of data and submitting another — and is exactly why the web app must freeze the answers and resend that same set rather than re-reading the form.
Notable security properties
- The OTP code is never stored in plaintext — the email channel stores a bcrypt hash, while for SMS the provider generates and verifies the code and the system keeps only a token for later verification.
- Full phone numbers and email addresses are never written to the session at all, only the masked value — a Redis leak would not expose member contact details.
- The SMS provider's secret is stored encrypted in the database (AES-256-GCM) and decrypted with a key derived from configuration only. That key must match the one the CMS used to encrypt it; a mismatch means decryption fails and no SMS goes out.
- Throttling is per session, not per IP — the three-resend cap and the 60-second cooldown are bound to the reference, so starting a fresh round grants a fresh quota. The application-level IP rate limiter still applies on top.
Edge cases worth knowing
- The session lives for only three minutes — shorter than many users expect. Switching to another app to read the code and coming back slowly really does produce
OTP_EXPIRED. - Without Redis, OTP does not work at all. Every method returns an error (it does not crash), because the session store lives entirely in Redis.
- All OTP errors arrive as raw objects carrying a
codekey, not the standard envelope — which matches how the web app's error-translation module is built, and is why it must also handle a code nested inside the message. - All error text originates from the backend. The web app should display what it receives, except for codes it maps to admin-configured copy.