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.
- Load the form, verify
x-liff-token, and checkrequireLineLogin. - Read
profile_mapping.otp. If OTP is disabled or has nofields, respond 400 withOTP is not enabled for this form. - Re-run member matching server-side on every request using
answersfrom the body — client-supplied matches are never trusted. On no match, respondPROFILE_NOT_FOUNDand send no OTP at all. - 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 withPlease choose a verification channel.A channel not configured on the form is rejected with 400, and a resend whoserefbelongs to a different form returnsOTP_EXPIRED. - Resolve the destination from the configured column in
customer_database_row.dataon the matched row. If empty, respond 400 with codeOTP_NO_CONTACT. - Load the OA's
otp_configand decryptthaibulksms_secret_encusing AES-256-GCM (keySHA-256(APP_ENCRYPT_SECRET), formatbase64(nonce + ciphertext)— matching what cms-api writes). - 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 codeOTP_NOT_CONFIGURED. - Resend guard — under 60 seconds since the previous send returns 429 with code
OTP_RESEND_COOLDOWN; after three sends, 429 with codeOTP_RESEND_LIMIT. - Deliver the code:
- SMS — normalize the number to MSISDN and let ThaiBulkSMS generate the code. Only the
tbsTokenis 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.
- SMS — normalize the number to MSISDN and let ThaiBulkSMS generate the code. Only the
- Write the session to Redis under key
otp:{ref}, whererefis a random 128-bit hex value, with a three-minute TTL. A resend resets the TTL, since it carries a new code. - Return
{ref, channel, destinationMasked, expiresIn}with status 201.
Verifying a code — POST /api/form-builder/:hash/otp/verify
- Load the form, verify the token, and check
requireLineLogin. An emptyreforpinreturns 400. - Load the session. Missing or expired returns code
OTP_EXPIRED; an already-verified session passes straight through. - If five attempts have already been made, delete the session and return code
OTP_MAX_ATTEMPTS. - 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. - On success, set
verified = trueand update the session without resetting the TTL, so the verification window cannot be extended by guessing. - On failure, increment the attempt counter. At five attempts the session is deleted and
OTP_MAX_ATTEMPTSis returned; otherwise respond 400 with codeOTP_INVALID_PINand a message stating how many attempts remain. - A session whose
formIddoes not match the current form is rejected withOTP_EXPIREDas defence in depth. - 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:
otpRefis present in the body- the session still exists and has
verified = true session.formIdequalsform.idsession.matchedRowIdequals the row re-matched during submit
Key Files & Functions
| Route | Handler |
|---|---|
POST /api/form-builder/:hash/otp/request | internal/formsubmission/otp.go → (*Handler).OTPRequest → (*Service).OTPRequest |
POST /api/form-builder/:hash/otp/verify | (*Handler).OTPVerify → (*Service).OTPVerify |
internal/formsubmission/otp.go—enforceOTP,decodeOTPMapping,resolveOTPField,mapOTPError,transformAnswers,handleNotFoundErrinternal/otp/session.go—Session,SessionStore(Create/Get/Update/Delete),GenerateRef, with constantsSessionTTL=3m,MaxAttempts=5,MaxSends=3,ResendCooldown=60s, and theotp:key prefixinternal/otp/service.go—NewService,LoadOAConfig,ResolveContact,Send,dispatch,Verify,checkPin,assertChannelConfigured,decryptSecret,renderEmailinternal/otp/mask.go—MaskPhone,MaskEmail,NormalizeMSISDNinternal/otp/code/code.go—Generate6,Hash,Equalinternal/otp/thaibulksms/thaibulksms.go—Request,Verify,BaseURLinternal/otp/email/email.go—Send,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
redisbacks 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) andcustomer_database_row. - Configuration —
APP_ENCRYPT_SECRETmust match the value cms-api used to encrypt the secret, plusOTP_THAIBULKSMS_URLand 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-verificationfeature.