Ad – 728×90
🌐 Portal Development

Odoo Portal Security – Access Tokens and Permissions

Portal security requires attention at multiple layers: access rights let portal users read models, record rules restrict them to their own records, access tokens enable safe sharing, and _document_check_access ties it all together in controllers. Missing any layer can expose sensitive customer data.

⏱️ 20 min 🎯 Intermediate 📅 Updated 2026
What you'll learn:
  • 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_access safely
  • 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:

  1. Access rights (ir.model.access.csv) — what models they can read/write/create/delete
  2. 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:

XML
<!-- 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:

  1. Check if the current user owns the record (via normal ORM access check)
  2. If that fails, validate the access_token parameter against the record's stored token

If both fail, it raises AccessError. If either succeeds, it returns a sudoed recordset:

Python
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})
Ad – 728×90

Preventing Data Leaks

Common mistakes that expose data unintentionally:

MistakeRiskFix
Using sudo() on listing page without domainAll records exposedAlways pass ownership domain to search()
No record rule for group_portalPortal users can enumerate all records via ORMAdd ir.rule scoped to base.group_portal
Returning full record JSON in API routeSensitive fields exposedWhitelist fields explicitly when returning JSON
Not validating loan_id in URLIDOR — guess any ID to access any recordAlways 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:

Python
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',
)
Key takeaways:
  • 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_access on 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

Frequently Asked Questions

Can a portal user reset their own access token?

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.

What happens if I forget the record rule for group_portal?

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.

Is it safe to use sudo() in portal controllers?

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.

How do I share a portal link that works without login?

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.