Skip to main content

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​

  1. Open Settings > OTP Verification. The form always renders, even before anything has been configured (GET /otp-config returns the empty/default config and never 404s), pre-filled with the default email subject and body.
  2. 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.
  3. 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.
  4. 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).
  5. 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 as base64(nonce ‖ ciphertext ‖ tag) in thaibulksms_secret_enc. On read, the server decrypts in memory only to compute the masked view (maskSecret keeps the first 2 and last 2 characters and asterisks the rest; a secret of 4 characters or fewer is fully asterisked) and returns only thaibulksmsSecretMasked — 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_SECRET was 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 because smsConfigured checks 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:
    • smsEnabled requires a non-empty key and a non-empty merged secret; otherwise 400 ThaiBulkSMS key and secret are required when SMS OTP is enabled
    • emailEnabled requires a non-empty subject and a body containing {otp}; otherwise 400 Email 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 != "" and emailConfigured = 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-config is optional and strings are trimmed — an absent or null field resolves to false/"", 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_COOLDOWN The "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 returned token on the session. We never learn the code. To verify, it sends token+pin to POST /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.
  • 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_id alone, not organization_id — safe, because line_oa_id carries 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_id is used only when inserting a new row.
  • There is no delete path — this module has exactly 2 routes (GET and PUT) and behaves as a per-OA singleton upsert.
  • Form builder — the sole consumer of this configuration. Its Verification tab reads smsConfigured/emailConfigured from here to decide what's selectable, and allows only one verification field per form. On top of that, Form builder's backend reads the otp_config table 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_SECRET is 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-config wipes 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 PermissionGuard checking the VIEW action on the otp-config subject, and the sidebar entry (plus quick access) is gated on the same condition. The backend subject that unlocks it is form-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 no PermissionGuard at all.
  • Backend — verified there is no ModuleGate. Checked across the whole repository: modulegate.ModuleGate(...) is used in 15 modules and otpconfig is not one of them. A platform admin cannot close this module for a specific organization (even with the form-builder module 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.CheckPolicies is 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 authed group, so they still sit behind the global JwtAuthGuard, which requires the token to carry a lineOaId claim — this is where the module is tighter than BigQuery Sync, which registers on the public group via @Public() and uses the jwt-login strategy 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 public group with jwt-login, and (2) OTP Verification retains a frontend PermissionGuard and 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 and PermissionGuard (subject otp-config)
  • src/components/otp-config/otp-config-form.tsx — the SMS/email cards, status tags, secret placeholder, {otp} validator
  • src/services/otp-config.service.ts, src/services/types/otp-config.type.ts — both endpoints and the meaning of the *Configured flags
  • src/components/ability/permission-map.ts — the 'form-builder' → [FORM_BUILDER, OTP_CONFIG] mapping
  • src/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 the authed group, no-op CheckPolicies, no ModuleGate
  • internal/modules/otpconfig/service.go — the singleton upsert, resolveSecretEnc, decrypt-to-mask
  • internal/modules/otpconfig/crypto.go — AES-256-GCM and maskSecret
  • internal/modules/otpconfig/dto.go — the cross-field rules and the smsConfigured/emailConfigured logic
  • internal/modules/formbuilder/otp.go — Form builder's otp_config guard, 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 cooldown
  • internal/otp/service.go — per-OA config loading, the send-time readiness re-check, {otp}/{ref} substitution
  • internal/otp/code/code.go — 6-digit generation and bcrypt (email channel only)
  • internal/otp/thaibulksms/thaibulksms.go — /v2/otp/request and /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