Skip to main content

OTP Verification in Forms

Overview

A six-digit code sent by SMS or email must be verified before a form will accept an answer set. This step applies to forms that have profile mapping enabled.

The key design point: once the submitter's answers match a row in the customer database, the code is sent to the contact recorded on that row — not to the phone number or email address the submitter typed in. This proves the person filling out the form actually owns the record.

Sessions live in Redis for three minutes. The code is never stored in plaintext, and the full phone number or email address is never written to the session at all — only a masked form of it.

Business Flow

Requesting a code — POST /api/form-builder/:hash/otp/request

The same endpoint doubles as "resend" when a ref value is supplied.

  1. Load the form, verify x-liff-token, and check requireLineLogin.
  2. Read profile_mapping.otp. If OTP is disabled or has no fields, respond 400 with OTP is not enabled for this form.
  3. Re-run member matching server-side on every request using answers from the body — client-supplied matches are never trusted. On no match, respond PROFILE_NOT_FOUND and send no OTP at all.
  4. Select the delivery channel in this order: body.channel, then the channel from the existing session (on resend), then the sole configured field if only one exists. Otherwise respond 400 with Please choose a verification channel. A channel not configured on the form is rejected with 400, and a resend whose ref belongs to a different form returns OTP_EXPIRED.
  5. Resolve the destination from the configured column in customer_database_row.data on the matched row. If empty, respond 400 with code OTP_NO_CONTACT.
  6. Load the OA's otp_config and decrypt thaibulksms_secret_enc using AES-256-GCM (key SHA-256(APP_ENCRYPT_SECRET), format base64(nonce + ciphertext) — matching what cms-api writes).
  7. Confirm the channel is fully configured: SMS requires the channel enabled plus a key and secret; email requires the channel enabled plus a subject and body, and the body must contain the {otp} placeholder. Anything missing yields 400 with code OTP_NOT_CONFIGURED.
  8. Resend guard — under 60 seconds since the previous send returns 429 with code OTP_RESEND_COOLDOWN; after three sends, 429 with code OTP_RESEND_LIMIT.
  9. Deliver the code:
    • SMS — normalize the number to MSISDN and let ThaiBulkSMS generate the code. Only the tbsToken is stored for later verification.
    • Email — generate the six-digit code locally, render the template by substituting {otp} and {ref}, send through the OA's SMTP, and store a bcrypt hash of the code in the session.
  10. Write the session to Redis under key otp:{ref}, where ref is a random 128-bit hex value, with a three-minute TTL. A resend resets the TTL, since it carries a new code.
  11. Return {ref, channel, destinationMasked, expiresIn} with status 201.

Verifying a code — POST /api/form-builder/:hash/otp/verify

  1. Load the form, verify the token, and check requireLineLogin. An empty ref or pin returns 400.
  2. Load the session. Missing or expired returns code OTP_EXPIRED; an already-verified session passes straight through.
  3. If five attempts have already been made, delete the session and return code OTP_MAX_ATTEMPTS.
  4. Check the code — SMS calls ThaiBulkSMS verify with the tbsToken; email compares against the bcrypt hash. Transport failures do not count as a wrong attempt.
  5. On success, set verified = true and update the session without resetting the TTL, so the verification window cannot be extended by guessing.
  6. On failure, increment the attempt counter. At five attempts the session is deleted and OTP_MAX_ATTEMPTS is returned; otherwise respond 400 with code OTP_INVALID_PIN and a message stating how many attempts remain.
  7. A session whose formId does not match the current form is rejected with OTP_EXPIRED as defence in depth.
  8. On success, return {verified: true} with status 200.

The submit-time gate (enforceOTP)

A form with OTP enabled must satisfy all of the following, or the submission fails with 400 and code OTP_REQUIRED:

  • otpRef is present in the body
  • the session still exists and has verified = true
  • session.formId equals form.id
  • session.matchedRowId equals the row re-matched during submit

Key Files & Functions

RouteHandler
POST /api/form-builder/:hash/otp/requestinternal/formsubmission/otp.go(*Handler).OTPRequest(*Service).OTPRequest
POST /api/form-builder/:hash/otp/verify(*Handler).OTPVerify(*Service).OTPVerify
  • internal/formsubmission/otp.goenforceOTP, decodeOTPMapping, resolveOTPField, mapOTPError, transformAnswers, handleNotFoundErr
  • internal/otp/session.goSession, SessionStore (Create/Get/Update/Delete), GenerateRef, with constants SessionTTL=3m, MaxAttempts=5, MaxSends=3, ResendCooldown=60s, and the otp: key prefix
  • internal/otp/service.goNewService, LoadOAConfig, ResolveContact, Send, dispatch, Verify, checkPin, assertChannelConfigured, decryptSecret, renderEmail
  • internal/otp/mask.goMaskPhone, MaskEmail, NormalizeMSISDN
  • internal/otp/code/code.goGenerate6, Hash, Equal
  • internal/otp/thaibulksms/thaibulksms.goRequest, Verify, BaseURL
  • internal/otp/email/email.goSend, SMTP

Error codes in use: OTP_RESEND_COOLDOWN, OTP_RESEND_LIMIT, OTP_EXPIRED, OTP_INVALID_PIN, OTP_MAX_ATTEMPTS, OTP_NOT_CONFIGURED, OTP_NO_CONTACT

Connections to Other Services

  • Redis — the client named redis backs the session store. Without Redis every method returns an error rather than panicking.
  • Database — tables otp_config (thaibulksms_key, thaibulksms_secret_enc, sms_enabled, email_enabled, email_subject, email_body) and customer_database_row.
  • ConfigurationAPP_ENCRYPT_SECRET must match the value cms-api used to encrypt the secret, plus OTP_THAIBULKSMS_URL and the SMTP variables (OTP_SMTP_*).
  • ThaiBulkSMS — the SMS provider, which also generates and verifies the code on the SMS channel.
  • Related features — follows form profile mapping and acts as the seventh gate in the form submission pipeline.
  • client-web — corresponds to the form-otp-verification feature.