Broke Login & Locked Out 847 Users? Add Fields to Odoo 18 Login Page Safely
By Braincuber Team
Published on December 22, 2025
Developer needs custom login field. Requirement: Add OTP field for two-factor auth. First attempt: Copy-paste code from Stack Overflow (Odoo 14 answer). Doesn't work in Odoo 18 (template structure changed). Second attempt: Override login template, break entire authentication system. Can't login to fix it. Database corrupted. Third attempt: 23 hours later, hire consultant to restore backup + implement properly = $2,300 wasted. All because login page customization broke auth flow.
Your login customization disasters: Added field to template, forgot backend validation = users bypass security. Modified controller wrong way = infinite redirect loop, locked out of system. Broke Odoo upgrade path (customized core files) = can't upgrade for 8 months. OTP emails not sending = users can't login = support flooded with 127 tickets. JavaScript errors in console = login button broken in Safari. No testing = deployed Friday 5pm = weekend emergency recovery.
Cost: Emergency consultant 23 hours × $100/hour = $2,300 to restore system. 127 support tickets × 17 min average = 36 hours × $50 = $1,800 wasted. 847 users can't login for 4.7 hours = lost productivity $43,780 (847 × $50/hour × 4.7 × 0.2 productivity impact). Broken upgrade path: Can't upgrade 8 months = miss security patches = vulnerability exploited = $127,400 ransomware recovery. Developer quit from stress after breaking production = $87,000 replacement hiring cost.
Odoo 18 Login Page Customization done right: Inherit (don't override) web.login template, extend res.users model with proper fields, create controller inheriting from Home class, implement JavaScript using publicWidget pattern, handle errors gracefully (don't break login if OTP fails), test in development first (NEVER deploy untested login changes). Here's how to add custom fields to login page without destroying your authentication system.
You're Risking System Downtime If:
Use Case: Add OTP Field for Two-Factor Authentication
We'll add OTP (One-Time Password) field to login page for enhanced security.
Implementation Flow:
- User enters email + password → Click login
- System validates credentials → Generates 6-digit OTP
- Sends OTP to user's email
- Shows OTP input field on login page
- User enters OTP → System validates
- If correct → Login successful
- If wrong → Show error, allow retry
Step 1: Extend res.users Model
Add field to store OTP in user record.
Create Module Structure
custom_login_otp/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── res_users.py
├── controllers/
│ ├── __init__.py
│ └── main.py
├── views/
│ └── login_templates.xml
└── static/
└── src/
└── js/
└── otp_login.js
File: models/res_users.py
from odoo import models, fields
class ResUsers(models.Model):
_inherit = 'res.users'
otp = fields.Char(
string='OTP Code',
help="One-Time Password for login authentication"
)
⚠️ Security Note:
OTP stored temporarily, cleared after use. In production, consider encrypting OTP or using time-based expiration (store timestamp, expire after 5 min).
Step 2: Create OTP Controller
Controller handles OTP generation, email sending, and validation.
File: controllers/main.py
import random
import math
from odoo import http
from odoo.http import request
from odoo.exceptions import AccessDenied
from odoo.addons.web.controllers.home import Home
class OTPLoginController(Home):
def _generate_otp(self):
"""Generate a secure 6-digit numeric OTP."""
digits = "0123456789"
return ''.join(random.choice(digits) for _ in range(6))
@http.route('/web/login/send_otp', type='json', auth='public')
def send_otp(self, login, password, **kwargs):
"""Authenticate credentials and send OTP via email."""
try:
# Authenticate the user
db = request.session.db
uid = request.session.authenticate(db, login, password)
if not uid:
return {'success': False, 'error': 'Authentication Failed'}
user = request.env['res.users'].browse(uid).sudo()
# Generate and assign new OTP
otp_code = self._generate_otp()
user.write({'otp': otp_code})
# Send OTP email
template = request.env.ref('custom_login_otp.otp_email_template')
template.sudo().send_mail(
user.id,
force_send=True,
email_values={
'email_to': user.email,
'subject': f'{user.company_id.name}: Your Login OTP',
}
)
# Logout session to force OTP login
request.session.logout()
return {'success': True}
except AccessDenied:
return {'success': False, 'error': 'Invalid login credentials'}
@http.route('/web/login/verify_otp', type='json', auth='public')
def verify_otp(self, login, otp, **kwargs):
"""Verify submitted OTP against stored value."""
user = request.env['res.users'].sudo().search([('login', '=', login)])
if user and user.otp == otp:
# Clear OTP after successful use
user.sudo().write({'otp': False})
return {'success': True}
return {'success': False, 'error': 'Invalid OTP'}
🚨 Critical: Error Handling
If email sending fails, user can't login. Always implement fallback:
- Log email failures
- Provide admin bypass option
- Show user-friendly error (not "500 Internal Server Error")
Step 3: Extend Login Template
Add OTP input field to login form using template inheritance.
File: views/login_templates.xml
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="login_page_inherit" inherit_id="web.login">
<!-- Add OTP field after password field -->
<xpath expr="//div[hasclass('oe_login_buttons')]" position="before">
<div class="form-group field-otp d-none" id="otp_field_container">
<label for="otp">Enter OTP</label>
<input
type="text"
name="otp"
id="otp"
class="form-control"
placeholder="6-digit code from email"
maxlength="6"
autocomplete="off"
/>
</div>
</xpath>
</template>
</odoo>
✓ Why Inheritance Works:
- Uses
inherit_id="web.login"(not override) - XPath adds field without modifying core template
- Field hidden by default (
d-none), shown via JavaScript - Upgrade-safe: Core updates don't break customization
Step 4: Implement JavaScript Logic
Control dynamic login flow with JavaScript.
File: static/src/js/otp_login.js
/** @odoo-module **/
import publicWidget from '@web/legacy/js/public/public_widget';
import { jsonrpc } from '@web/core/network/rpc_service';
publicWidget.registry.OTPLogin = publicWidget.Widget.extend({
selector: '.oe_login_form',
events: {
'click button[type="submit"]': '_onLoginSubmit',
},
_onLoginSubmit: function (ev) {
ev.preventDefault();
ev.stopPropagation();
const $form = this.$el;
const login = $form.find('#login').val();
const password = $form.find('#password').val();
const otp = $form.find('#otp').val();
// If OTP field visible, validate OTP
if (!$form.find('#otp_field_container').hasClass('d-none')) {
this._verifyOTP(login, otp, $form);
return;
}
// Otherwise, send OTP first
this._sendOTP(login, password, $form);
},
_sendOTP: function (login, password, $form) {
const self = this;
jsonrpc('/web/login/send_otp', {
login: login,
password: password,
}).then(function (result) {
if (result.success) {
// Show OTP field
$form.find('#otp_field_container').removeClass('d-none');
$form.find('#otp').focus();
self._clearError();
// Show success message
self._showSuccess('OTP sent to your email. Please check your inbox.');
} else {
self._showError(result.error || 'Authentication failed.');
}
}).catch(() => {
self._showError('An error occurred while sending OTP.');
});
},
_verifyOTP: function (login, otp, $form) {
const self = this;
jsonrpc('/web/login/verify_otp', {
login: login,
otp: otp,
}).then(function (result) {
if (result.success) {
// Submit form to complete login
$form.submit();
} else {
self._showError(result.error || 'Invalid OTP.');
$form.find('#otp').val('').focus();
}
}).catch(() => {
self._showError('An error occurred during OTP verification.');
});
},
_showError: function (message) {
this._clearMessages();
this.$el.prepend(
`
${message}
`
);
},
_showSuccess: function (message) {
this._clearMessages();
this.$el.prepend(
`
${message}
`
);
},
_clearMessages: function () {
this.$el.find('.alert').remove();
},
_clearError: function () {
this._clearMessages();
},
});
export default publicWidget.registry.OTPLogin;
Step 5: Create Email Template
Email template for sending OTP to users.
File: data/email_template.xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="otp_email_template" model="mail.template">
<field name="name">Login OTP Email</field>
<field name="model_id" ref="base.model_res_users"/>
<field name="subject">{{ object.company_id.name }}: Your Login OTP</field>
<field name="email_from">{{ object.company_id.email }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="body_html" type="html">
<![CDATA[
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #555; width: 100%">
<tbody>
<tr>
<td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 24px; background-color: white; margin: 16px">
<tbody>
<tr>
<td style="font-size: 18px; font-weight: bold; padding-bottom: 16px;">
Login Verification Code
</td>
</tr>
<tr>
<td style="font-size: 14px; padding-bottom: 16px;">
Hello {{ object.name }},
</td>
</tr>
<tr>
<td style="font-size: 14px; padding-bottom: 16px;">
Your One-Time Password (OTP) for login is:
</td>
</tr>
<tr>
<td align="center" style="padding: 24px 0;">
<div style="background-color: #E10600; color: white; font-size: 32px; font-weight: bold; padding: 16px 32px; border-radius: 8px; letter-spacing: 8px;">
{{ object.otp }}
</div>
</td>
</tr>
<tr>
<td style="font-size: 14px; padding-bottom: 16px;">
This code will expire in 5 minutes for security purposes.
</td>
</tr>
<tr>
<td style="font-size: 12px; color: #888; padding-top: 16px; border-top: 1px solid #ddd;">
If you did not attempt to log in, please contact your system administrator immediately.
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
]]>
</field>
</record>
</data>
</odoo>
Step 6: Complete __manifest__.py
{
'name': 'Custom Login OTP',
'version': '18.0.1.0.0',
'category': 'Authentication',
'summary': 'Add OTP field to login page for two-factor authentication',
'depends': ['base', 'web', 'mail'],
'data': [
'views/login_templates.xml',
'data/email_template.xml',
],
'assets': {
'web.assets_frontend': [
'custom_login_otp/static/src/js/otp_login.js',
],
},
'installable': True,
'application': False,
'auto_install': False,
}
Testing Checklist
Before Deploying to Production:
- Test in development database FIRST
- NEVER test login changes in production
- Create copy of production DB for testing
- Test all scenarios:
- ✓ Valid credentials + valid OTP = Login success
- ✓ Valid credentials + wrong OTP = Error shown, allow retry
- ✓ Invalid credentials = Error shown before OTP step
- ✓ Email not sending = User sees helpful error
- ✓ JavaScript disabled = Fallback works
- Test multiple browsers:
- Chrome, Firefox, Safari, Edge
- Mobile browsers (iOS Safari, Chrome Mobile)
- Test email delivery:
- Check spam folder
- Verify OTP displays correctly in email
- Test with multiple email providers (Gmail, Outlook, etc.)
- Performance test:
- 100 concurrent login attempts
- Email queue not overwhelmed
- Admin bypass:
- Keep one admin account WITHOUT OTP requirement
- In case OTP system breaks, can still login to fix
Common Mistakes
1. Overriding Instead of Inheriting Template
Created custom web.login template, lost all core updates. Broke after Odoo upgrade.
Fix: Always use inherit_id="web.login" with XPath. Never override core templates.
2. No Error Handling for Email Failures
Email server down = OTP never sent = users can't login = 847 users locked out.
Fix: Wrap email sending in try/except, show user-friendly error, provide admin contact info.
3. OTP Never Expires
OTP valid forever. Attacker intercepts email days later, still works.
Fix: Add timestamp field, check if OTP < 5 minutes old. Clear expired OTPs.
4. Deployed Friday 5pm
Pushed login changes end of Friday. Bug discovered. Entire team locked out. Weekend emergency recovery.
Fix: NEVER deploy authentication changes on Fridays or before holidays. Deploy Monday-Wednesday only.
Advanced: Add OTP Expiration
Enhance security by making OTP time-limited (5 minute expiry).
Enhanced res.users Model
from odoo import models, fields, api
from datetime import datetime, timedelta
class ResUsers(models.Model):
_inherit = 'res.users'
otp = fields.Char(string='OTP Code')
otp_timestamp = fields.Datetime(string='OTP Generated At')
def validate_otp(self, otp_code):
"""Validate OTP and check expiration (5 minutes)."""
if not self.otp or not self.otp_timestamp:
return False
# Check if OTP matches
if self.otp != otp_code:
return False
# Check if OTP expired (5 minutes)
expiry_time = self.otp_timestamp + timedelta(minutes=5)
if datetime.now() > expiry_time:
# Clear expired OTP
self.write({'otp': False, 'otp_timestamp': False})
return False
# OTP valid, clear it
self.write({'otp': False, 'otp_timestamp': False})
return True
Updated Controller
from datetime import datetime
# In send_otp method, add timestamp:
user.write({
'otp': otp_code,
'otp_timestamp': datetime.now()
})
# In verify_otp method:
@http.route('/web/login/verify_otp', type='json', auth='public')
def verify_otp(self, login, otp, **kwargs):
user = request.env['res.users'].sudo().search([('login', '=', login)])
if user and user.validate_otp(otp):
return {'success': True}
return {'success': False, 'error': 'Invalid or expired OTP'}
Pro Tip: Test login customizations in ISOLATED development database. Keep backup admin account WITHOUT OTP requirement. If OTP system breaks, you can still login to fix it. NEVER deploy login changes Friday afternoon.
Need Custom Login Features Without Breaking Production?
We implement Odoo login customizations the safe way: template inheritance, proper error handling, comprehensive testing. No weekend emergencies, no locked-out users.
