Settings — OTP Verification
Overview
OTP Verification is where the credentials and message templates for sending one-time passcodes live, at the level of a single LINE OA. This page does not decide which forms require verification (that setting lives in the Verification tab of Form builder) — it is the layer underneath that answers which channels this OA can send an OTP on. Two channels are supported: SMS (via ThaiBulkSMS) and email (via the channel's SMTP).
One thing to understand before configuring: the two channels work fundamentally differently. On the SMS channel, ThaiBulkSMS generates and sends the code itself — our system only asks them to send one, keeps a token, and later asks them whether the code the user typed was correct. So the SMS message content is configured on the ThaiBulkSMS side, not here. This page's template fields therefore apply to email only, which is the channel where our system generates the 6-digit code itself.
Who uses it — administrators who configure this once for the whole account before form-building teams can turn OTP on in their forms (this module has no ModuleGate on the backend — see Access control below).
User journey
- Open Settings > OTP Verification. The form always renders, even before anything has been configured (
GET /otp-configreturns the empty/default config and never 404s), pre-filled with the default email subject and body. - The SMS — ThaiBulkSMS card has 3 fields:
- The enable-SMS-OTP switch
- ThaiBulkSMS Key
- ThaiBulkSMS Secret — a password field whose value is always blank on load, with the masked value shown as a placeholder ("Current: ab****yz — leave blank to keep") so you know a secret already exists. Leaving it blank means keep the stored secret, not clear it.
- This card has no frontend validation at all — you can flip the switch, fill in nothing, and hit save, then read the backend's error.
- The Email card has 3 fields:
- The enable-email-OTP switch
- Email Subject — default
Your verification code(TH:รหัสยืนยันของคุณ) - Email Body (HTML) — default
Your OTP code is <b>{otp}</b>. It expires in 3 minutes.(TH:รหัส OTP ของคุณคือ <b>{otp}</b> จะหมดอายุใน 3 นาที). It must always contain{otp}; add{ref}too if you want to show the reference code.
- Save — the status tag on each card header updates to "Configured" once that channel meets its conditions (the tag reflects the persisted config, not the current form state, so it updates after the refetch).
- Go to Form builder, open the target form, turn on Profile Mapping with a Member Database selected (required, because the OTP goes to a column of the matched record, never to what the filler typed), then open the Verification tab to enable OTP for that form — a channel that is still "not configured" is disabled there, with a link back to this page.
Key details verified against the source
Confirmed from internal/modules/otpconfig/{controller,service,dto,crypto}.go in line-management-cms-api-go, and internal/otp/{service,session,mask}.go plus internal/otp/{code,thaibulksms} in line-management-client-api-go:
- The secret is encrypted at rest with AES-256-GCM, and there is no way to read the real value back through the API — the key is derived by SHA-256 over
APP_ENCRYPT_SECRET, a fresh nonce is generated per seal, and the value is stored asbase64(nonce ‖ ciphertext ‖ tag)inthaibulksms_secret_enc. On read, the server decrypts in memory only to compute the masked view (maskSecretkeeps the first 2 and last 2 characters and asterisks the rest; a secret of 4 characters or fewer is fully asterisked) and returns onlythaibulksmsSecretMasked— the plaintext never enters the returned struct. This is a stark contrast with the BigQuery Sync menu, which stores its service account key in plaintext and returns the whole thing. - A failed decrypt never returns a 500 — if, say,
APP_ENCRYPT_SECRETwas rotated or misconfigured, the server logs it and returns an empty masked value instead (a deliberate "never trust broken config" posture). The visible symptom is that the secret field's placeholder reverts to "Enter ThaiBulkSMS secret" as though nothing were stored, while the database still holds it — and becausesmsConfiguredchecks the encrypted value rather than the decryptable one, the status tag still reads "Configured" even though a real send would fail. - "Blank means keep" is handled on the backend in a separately testable function (
resolveSecretEnc) — a non-empty submitted secret is freshly encrypted and replaces the old one; an empty one always leaves the existing one untouched; no existing row and no submitted secret resolves to "no secret at all." The side effect worth knowing: nothing in the UI can ever "delete" a stored secret, because blank always means keep. - The cross-field rules run after merging with the existing secret — that's what lets you flip the SMS switch on without retyping the secret when one is already stored. The rules checked are:
smsEnabledrequires a non-empty key and a non-empty merged secret; otherwise 400ThaiBulkSMS key and secret are required when SMS OTP is enabledemailEnabledrequires a non-empty subject and a body containing{otp}; otherwise 400Email subject and a body containing {otp} are required when email OTP is enabled
- The "Configured" tag means "enabled and complete," not just "credentials saved" —
smsConfigured = smsEnabled && key != "" && secretEnc != ""andemailConfigured = emailEnabled && subject != "" && strings.Contains(body, "{otp}"). Turning a switch off flips the tag straight back to "Not configured" even with all the data still present — and because Form builder reads these two booleans to decide what's selectable, turning a switch off here takes effect immediately for every form using that channel. - Every field in
PUT /otp-configis optional and strings are trimmed — an absent or null field resolves tofalse/"", not "leave the stored value alone." So a partial PUT wipes the fields it didn't send, with the secret's "blank means keep" being the single exception. The web app always sends every field, so it never hits this, but direct API calls must be careful. {otp}is checked as a literal substring, at three independent layers — the frontend (value.includes('{otp}'), case-sensitive), cms-api on save, and client-api again at actual send time (Email OTP template is missing the {otp} placeholder.).{ref}is never validated at any layer — include it or don't. The email subject isn't checked for placeholders at all, and an{otp}placed in the subject is sent out literally, because substitution runs only on the body.- None of the values that govern real OTP behavior live on this page — the whole set is hardcoded as constants in
line-management-client-api-go, with nothing in the CMS to change them:- 3-minute code lifetime (the Redis session TTL)
- 5 verify attempts, with the 5th failure killing the session
- 3 total sends per session (the initial send counts as one)
- 60-second resend cooldown, exceeded gives a 429
OTP_RESEND_COOLDOWNThe "3 minutes" in the default email body is just copy — it isn't read from the constant, so if the constant ever changes, the email body won't follow.
- SMS and email use entirely different code mechanics
- SMS — the system calls ThaiBulkSMS's
POST /v2/otp/request(form-urlencoded: key, secret, msisdn) and keeps the returnedtokenon the session. We never learn the code. To verify, it sends token+pin toPOST /v2/otp/verify. The phone number is normalized to local format first (+66/66…→0…, with every non-digit stripped). - Email — the system generates its own 6-digit code with
crypto/rand, stores it as a bcrypt hash on the session (never the plaintext), compares with bcrypt's constant-time digest compare, and sends over the channel's SMTP with{otp}and{ref}substituted into the body.
- SMS — the system calls ThaiBulkSMS's
- The OTP session never stores the real phone/email — it holds only the masked form (
0819188051→08xxxxx051,[email protected]→a***@e***.com), and the send path re-resolves the member record on every call, including resends. The destination is therefore never trusted from the client, and nothing that could leak a phone number or address lives in Redis. - client-api re-checks channel readiness at send time — verifying the switch, key, and secret for SMS, and the switch, subject, a non-empty body, and
{otp}for email, answering with codes the filler can see (OTP_NOT_CONFIGURED), plus a distinct code for a matched record that has no contact for the chosen channel (OTP_NO_CONTACT). - Config reads and writes are scoped by
line_oa_idalone, notorganization_id— safe, becauseline_oa_idcarries a unique constraint (one row per OA) and comes from a token claim, but it differs from other modules in the system, which filter on both.organization_idis used only when inserting a new row. - There is no delete path — this module has exactly 2 routes (
GETandPUT) and behaves as a per-OA singleton upsert.
Related menus in this journey
- Form builder — the sole consumer of this configuration. Its Verification tab reads
smsConfigured/emailConfiguredfrom here to decide what's selectable, and allows only one verification field per form. On top of that, Form builder's backend reads theotp_configtable directly as a second layer (validateOtpConfigGuard) and returns a 400 if a form asks for a channel that isn't ready — so calling the API directly to bypass the UI cannot turn OTP on. - Member Database — a hard prerequisite on the form side whenever OTP is on, because the code goes to a column of the matched member record. The "column to verify" options in that tab come from the selected database's columns, not from the form's questions.
- These three menus form a chain — turn Profile Mapping off or remove the Member Database and Form builder's Verification tab immediately can't be enabled (the system disables the switch for you); turn a channel switch off here and any form using that channel shows an error state in the tab and can no longer be saved.
Things to watch out for
- There is no test-send button and no send history in this menu — verified: the module has exactly 2 endpoints (GET/PUT) and the only button is Save. You must submit a real form to confirm the credentials work.
- Turning a channel switch off takes effect immediately for every form using that channel — check whether any live forms depend on it first.
- There is no way to read the stored secret back, and no way to delete it either — if you've forgotten it, issue a fresh key/secret pair from the provider and overwrite.
- The SMS message is not configured here — ThaiBulkSMS generates and sends the code itself. To change the SMS text or sender name, do it in ThaiBulkSMS. The template fields on this page affect email only.
- The SMS card has no frontend validation at all — flip the switch, leave the fields empty, and save goes through; you only see the backend error afterwards (unlike the email card, which pre-checks
{otp}). - The "3 minutes" in the default email body is plain copy — if you rewrite the body citing a different duration, it won't match real behavior (the TTL is a fixed 3 minutes in client-api's source).
- If
APP_ENCRYPT_SECRETis rotated, the status tag still reads "Configured" while the secret placeholder reverts to empty and real SMS sends fail — watch for exactly that symptom. The fix is to re-enter the secret so it is encrypted with the current key. - A partial
PUT /otp-configwipes the fields you didn't send (except the secret) — always read the current values with GET and send them all back if you call the API directly.
Access control (verified against the source)
- Frontend — wrapped in a
PermissionGuardchecking theVIEWaction on theotp-configsubject, and the sidebar entry (plus quick access) is gated on the same condition. The backend subject that unlocks it isform-builder, shared with the Form builder menu (mapping:'form-builder' → [FORM_BUILDER, OTP_CONFIG]) — so anyone who can see Form builder always sees this menu too. That is tighter than User Attribute Setup and BigQuery Sync, which have noPermissionGuardat all. - Backend — verified there is no
ModuleGate. Checked across the whole repository:modulegate.ModuleGate(...)is used in 15 modules andotpconfigis not one of them. A platform admin cannot close this module for a specific organization (even with theform-buildermodule closed, the OTP settings routes remain callable). - Policy annotations exist but are no-ops —
CheckPolicies(read/update, otp-config)is attached to both routes, and the file's own comment states that "auth.CheckPoliciesis a no-op today — the metadata is kept for when enforcement is switched on." So in practice any logged-in user holding an OA-scoped token can read and modify that OA's entire OTP configuration, regardless of role. - Both routes register on the
authedgroup, so they still sit behind the global JwtAuthGuard, which requires the token to carry alineOaIdclaim — this is where the module is tighter than BigQuery Sync, which registers on thepublicgroup via@Public()and uses thejwt-loginstrategy that doesn't require that claim. - What partly compensates for the missing ModuleGate — the credentials are encrypted at rest and only ever returned masked, unlike BigQuery Sync where any logged-in user can read the service account key in full. Here, someone with access can modify or break the configuration, but cannot exfiltrate the stored credentials for reuse.
- Verdict against the earlier technical documentation — the claim that OTP Verification and BigQuery Sync "have no module gate and no real policy enforcement at all, fully open to any logged-in user" is verified correct, with two refinements: (1) BigQuery Sync is a further step weaker, because it sits on the
publicgroup withjwt-login, and (2) OTP Verification retains a frontendPermissionGuardand credential encryption as its remaining defenses. - No route requires Super Admin, and the module has no delete path.
Source file reference
Frontend (cms-web-2026)
src/app/otp-config/page.tsx— entry point andPermissionGuard(subjectotp-config)src/components/otp-config/otp-config-form.tsx— the SMS/email cards, status tags, secret placeholder,{otp}validatorsrc/services/otp-config.service.ts,src/services/types/otp-config.type.ts— both endpoints and the meaning of the*Configuredflagssrc/components/ability/permission-map.ts— the'form-builder' → [FORM_BUILDER, OTP_CONFIG]mappingsrc/components/form-builder/form/verification/verification-form.tsx— the consumer side reading channel readiness
Backend (line-management-cms-api-go)
internal/modules/otpconfig/controller.go— 2 routes on theauthedgroup, no-opCheckPolicies, no ModuleGateinternal/modules/otpconfig/service.go— the singleton upsert,resolveSecretEnc, decrypt-to-maskinternal/modules/otpconfig/crypto.go— AES-256-GCM andmaskSecretinternal/modules/otpconfig/dto.go— the cross-field rules and thesmsConfigured/emailConfiguredlogicinternal/modules/formbuilder/otp.go— Form builder'sotp_configguard, which reads this table directly
Client API (line-management-client-api-go) — the service that actually sends and verifies OTPs
internal/otp/session.go— 3-minute TTL, 5 attempts, 3 sends, 60-second cooldowninternal/otp/service.go— per-OA config loading, the send-time readiness re-check,{otp}/{ref}substitutioninternal/otp/code/code.go— 6-digit generation and bcrypt (email channel only)internal/otp/thaibulksms/thaibulksms.go—/v2/otp/requestand/v2/otp/verify(ThaiBulkSMS generates the code)internal/otp/mask.go— phone normalization to local format, and phone/email masking
See the documentation team's earlier technical write-up at OTP Verification settings