Portal Security: Don't Leak One Customer's Data to Another
The portal exposes records to external users over the web, so security is critical. The cardinal sin: showing customer A the records of customer B. Odoo has layered defenses, and you must respect all of them.
| Layer | Controls |
|---|---|
Access rights (ir.model.access) | which models a group can read/write at all |
Record rules (ir.rule) | which records within a model a user sees |
Controller auth | who can hit the URL (public/user) |
@http.route("/my/orders/<int:order_id>", auth="user", website=True)
def portal_order(self, order_id, **kw):
order = request.env["sale.order"].browse(order_id)
# ✅ verify ownership before rendering — never trust the URL id
try:
order.check_access_rights("read")
order.check_access_rule("read")
except AccessError:
return request.redirect("/my")
return request.render("mymod.order_page", {"order": order})
The danger of sudo(): calling .sudo() bypasses all record rules and runs as superuser. Handy for reading config, but if you sudo() to fetch a record from a user-supplied id and render it, you've just handed any logged-in user access to every record — a classic portal data leak. Use sudo() narrowly, and always validate that the current user actually owns the record (via record rules or an explicit partner_id check) before showing it.
🏋️ Practical Exercise
- Filter portal records by
partner_id. - Scope
sudo()carefully. - Add an access-token check for shared links.
- Add a record rule for the portal group.
- Prevent access to other users’ records.
🔥 Challenge Exercise
Secure a portal feature so a customer can only see and edit their own documents: filter by partner_id, validate access tokens for shared links, and add a portal record rule. Explain why controllers must never trust URL IDs and how sudo() should be scoped narrowly.
- The portal security model: who is a portal user and what they can do
- How access tokens work and when to use them
- Record rules scoped to
base.group_portal - How to use
_document_check_accesssafely - Common mistakes that cause data leaks
Portal Security Model
Portal users belong to the base.group_portal security group. This group has no backend access — portal users cannot access /web/. Their permissions are limited to what you explicitly grant via:
- Access rights (
ir.model.access.csv) — what models they can read/write/create/delete - Record rules (
ir.rule) — which rows within a model they can access
Without a record rule, a portal user with read access to your model could potentially read all records. Always add a record rule.
Access Tokens
Access tokens are UUID-4 strings stored in the access_token field (added by portal.mixin). They allow unauthenticated access to a single record — useful for sharing a link with a third party who doesn't have a portal account.
Tokens are:
- Generated automatically when first needed via
_portal_ensure_token() - Passed as a URL query parameter:
/my/loans/42?access_token=abc123... - Resettable by the user via the "Regenerate Token" button in portal
Never expose access tokens in templates except inside the share URL. Don't render them as plain text.
Record Rules for Portal Users
A proper record rule for your model restricts portal users to records they own:
<!-- Grant read access to portal users -->
<!-- in security/ir.model.access.csv -->
<!-- access_library_loan_portal,library.loan portal,model_library_loan,base.group_portal,1,0,0,0 -->
<!-- Record rule: restrict to own loans -->
<record id="rule_library_loan_portal_user" model="ir.rule">
<field name="name">Library Loan: portal users see own records</field>
<field name="model_id" ref="model_library_loan"/>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="domain_force">
[('member_id.user_ids', 'in', [user.id])]
</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
The domain [('member_id.user_ids', 'in', [user.id])] resolves to: "the loan's member partner must have the current user in their user_ids list" — i.e., the member and the logged-in portal user are the same person.
_document_check_access
Use this helper in your detail page controller. It tries two things in order:
- Check if the current user owns the record (via normal ORM access check)
- If that fails, validate the
access_tokenparameter against the record's stored token
If both fail, it raises AccessError. If either succeeds, it returns a sudoed recordset:
from odoo.exceptions import AccessError, MissingError
try:
loan_sudo = self._document_check_access(
'library.loan', # model name
loan_id, # record ID from URL
access_token, # from request query string (None if not provided)
)
except (AccessError, MissingError):
return request.redirect('/my')
# loan_sudo is a sudo() recordset — safe to read any field
return request.render('my_module.portal_loan_detail', {'loan': loan_sudo})
Preventing Data Leaks
Common mistakes that expose data unintentionally:
| Mistake | Risk | Fix |
|---|---|---|
Using sudo() on listing page without domain | All records exposed | Always pass ownership domain to search() |
No record rule for group_portal | Portal users can enumerate all records via ORM | Add ir.rule scoped to base.group_portal |
| Returning full record JSON in API route | Sensitive fields exposed | Whitelist fields explicitly when returning JSON |
Not validating loan_id in URL | IDOR — guess any ID to access any record | Always use _document_check_access |
Field-Level Security
Sensitive fields (internal notes, cost price, employee salaries) should use the groups attribute so portal users can't read them even with sudo:
internal_notes = fields.Text(
string='Internal Notes',
groups='base.group_user', # internal users only
)
cost_price = fields.Float(
string='Cost Price',
groups='account.group_account_user',
)
- Portal users are in
base.group_portal— no backend access - Always add a record rule scoped to
base.group_portal— without it, access rights alone allow reading all records - Use
_document_check_accesson detail pages — never browse a record by ID without checking ownership first - Sensitive fields should use
groups='base.group_user'to prevent portal users from seeing them even via sudo
Interview Questions
- How do you ensure portal users only see their own data?
- Why can’t you trust record IDs from the URL?
- What is an access token in the portal?
- When is
sudo()appropriate, and when is it risky? - What is a record rule for the portal group?
Related Topics
FAQ
Yes — the portal mixin adds a "Regenerate Access Token" button to the portal detail page. This invalidates the old token immediately. Use this to revoke shared links.
Without a record rule, the ORM applies no row-level filter for that group. Any portal user with read access to your model can potentially call search([]) and retrieve all records — a serious data exposure risk.
Yes, with care. Use sudo() only after validating ownership (via _document_check_access or manual check). Never call sudo().search([]) on the listing page without an ownership domain — this would expose all records.
Generate the URL as {record.access_url}?access_token={record.access_token}. The detail page controller must use auth='public' and validate with _document_check_access passing the token.

