diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 13f4ee83f5..8218d10939 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -51,12 +51,15 @@
"column_break_34",
"allow_login_after_fail",
"two_factor_authentication",
+ "column_break_odhl",
"enable_two_factor_auth",
"bypass_2fa_for_retricted_ip_users",
"bypass_restrict_ip_check_if_2fa_enabled",
+ "column_break_bzfr",
"two_factor_method",
"lifespan_qrcode_image",
"otp_issuer_name",
+ "otp_sms_template",
"password_tab",
"password_settings",
"logout_on_password_reset",
@@ -752,12 +755,27 @@
"fieldtype": "Select",
"label": "Show External Link Warning",
"options": "Never\nAsk\nAlways"
+ },
+ {
+ "fieldname": "column_break_odhl",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "eval:doc.enable_two_factor_auth==1 && doc.two_factor_method==\"SMS\"",
+ "description": "OTP placeholder should be defined as {{ otp }} ",
+ "fieldname": "otp_sms_template",
+ "fieldtype": "Small Text",
+ "label": "OTP SMS Template"
+ },
+ {
+ "fieldname": "column_break_bzfr",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2025-09-24 16:04:02.016562",
+ "modified": "2025-11-04 16:47:54.230874",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py
index 9eb5d7f944..93a81d4465 100644
--- a/frappe/core/doctype/system_settings/system_settings.py
+++ b/frappe/core/doctype/system_settings/system_settings.py
@@ -89,6 +89,7 @@ class SystemSettings(Document):
"#,###",
]
otp_issuer_name: DF.Data | None
+ otp_sms_template: DF.SmallText | None
password_reset_limit: DF.Int
rate_limit_email_link_login: DF.Int
reset_password_link_expiry_duration: DF.Duration | None
@@ -145,6 +146,7 @@ class SystemSettings(Document):
self.validate_user_pass_login()
self.validate_backup_limit()
self.validate_file_extensions()
+ self.validate_otp_sms_template()
if not self.link_field_results_limit:
self.link_field_results_limit = 10
@@ -156,6 +158,17 @@ class SystemSettings(Document):
_("{0} can not be more than {1}").format(label, 50), alert=True, indicator="yellow"
)
+ def validate_otp_sms_template(self):
+ if not self.enable_two_factor_auth or self.two_factor_method != "SMS" or not self.otp_sms_template:
+ return
+
+ if "{{otp}}" not in self.otp_sms_template.replace(" ", ""):
+ frappe.throw(
+ _("OTP SMS Template must contain {0} placeholder to insert the OTP.").format(
+ "{{otp}}"
+ )
+ )
+
def validate_user_pass_login(self):
if not self.disable_user_pass_login:
return
diff --git a/frappe/twofactor.py b/frappe/twofactor.py
index 940e420ad2..adb6a80758 100644
--- a/frappe/twofactor.py
+++ b/frappe/twofactor.py
@@ -320,7 +320,8 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None):
return False
hotp = pyotp.HOTP(otpsecret)
- args = {ss.message_parameter: f"Your verification code is {hotp.at(int(token))}"}
+ otp = hotp.at(int(token))
+ args = {ss.message_parameter: get_rendered_otp_message(otp)}
for d in ss.get("parameters"):
args[d.parameter] = d.value
@@ -341,6 +342,14 @@ def send_token_via_sms(otpsecret, token=None, phone_no=None):
return True
+def get_rendered_otp_message(otp: str) -> str:
+ default_template = "Your verification code is {{otp}}"
+ custom_template = frappe.get_system_settings("otp_sms_template")
+ template = custom_template or default_template
+
+ return frappe.render_template(template, {"otp": otp})
+
+
def send_token_via_email(user, token, otp_secret, otp_issuer, subject=None, message=None):
"""Send token to user as email."""
user_email = frappe.db.get_value("User", user, "email")