← Back to Blog
Development by Featured

OCA auth_api_key vs Odoo's Native API Keys: A Short History of Why Both Exist

OCA auth_api_key vs Odoo's Native API Keys: A Short History of Why Both Exist

Odoo has had per-user API keys since 14.0, yet OCA still ships auth_api_key and the REST stacks depend on it. The reason is a decade of history — OCA got there first, core caught up, and the relationship changed. A code-grounded timeline.

This is a companion to Bootstrapping a /json/2 API Key for Odoo Mobile Apps. That post was about getting a native Odoo API key when all you have is a login and a password. This one steps back and asks a question that comes up on almost every integration project: Odoo has shipped per-user API keys since 14.0, so why does OCA still maintain auth_api_key — a whole separate key system — and why do the OCA REST stacks depend on it instead of the native one?

You can answer that with a feature table, but the honest answer is historical. auth_api_key was not built as an alternative to native keys — it predates them by two years. Core caught up, then kept going, and the relationship between the two changed as it did. So instead of a static comparison, here is the timeline, each step grounded in the code that shipped.


The timeline at a glance

flowchart TB
    A["2018 · Odoo 10<br/>OCA ships<br/>auth_api_key"]
    B["2020 · Odoo 14<br/>Core ships<br/>res.users.apikeys"]
    C["2021 · Odoo 14<br/>OCA adds<br/>group + server_env"]
    D["2024 · Odoo 17<br/>OCA adds<br/>fastapi_auth_api_key"]
    E["2024 · Odoo 18<br/>Core adds<br/>auth=bearer"]
    F["2025 · Odoo 19<br/>Core ships<br/>/json/2"]
    G["2025-26 · Odoo 19<br/>OCA mints<br/>native keys"]

    A -->|"core has no keys"| B
    B -->|"custom routes uncovered"| C
    C -->|"REST needs a hook"| D
    D -->|"core takes the boundary"| E
    E -->|"key becomes the front door"| F
    F -->|"bootstrap still missing"| G

The same story with the gaps spelled out:

Year Odoo What core shipped The gap OCA’s answer
2018 10 → XML-RPC / JSON-RPC, user + password only No key auth at all; custom controllers had to invent their own auth_api_key (Akretion seed 2017 → ACSONE, branch 10.0)
2020 14 Native res.users.apikeys — hashed, per-user, for RPC Doesn’t cover custom controllers OCA module still required
2021 14 No scoping, no secrets-out-of-DB auth_api_key_group, auth_api_key_server_env, base_rest_auth_api_key
2024 (early) 17 No first-class REST/FastAPI hook fastapi_auth_api_key
2024 (Sep) 18 Generic auth="bearer" method — native keys now guard custom routes The custom-controller boundary erodes
2025 19 /json/2 — bearer key becomes the primary external transport auth Service-identity provisioning, group gating, env secrets, bootstrap
2025–26 19 The bootstrap gap (see previous post) auth_api_key_native_generatecomplements core, doesn’t replace it

(Odoo columns are the branch each item first landed on — verified first-hand against git history — not the newest Odoo of that year. Verification dates and commits are cited inline below.)

The shape of the story: OCA arrived first, core caught up on the mainstream case and then, in Odoo 18, on the custom-controller boundary itself, and OCA moved from being a replacement to being an ecosystem layered around core. Let’s walk it.


2018: before Odoo had any API key

Back then the only external API was XML-RPC and JSON-RPC, and both authenticated with a username and password on every call. There was no notion of an API key anywhere in core. If you exposed a custom HTTP controller — say a REST endpoint for a partner integration — you had to roll your own authentication.

That is the gap auth_api_key was born to fill. The auth-method code traces back to Akretion’s work by Sébastien Beau in 2017; ACSONE packaged it as the auth_api_key module, whose first commit (6645f37e1, “New generic module du define REST services”) lands on the 10.0 branch on 2018-01-31 — its commit message names REST services as the reason it exists:

# server-auth/auth_api_key/models/ir_http.py
# Copyright 2018 ACSONE SA/NV
# Copyright 2017 Akretion — @author Sébastien BEAU
class IrHttp(models.AbstractModel):
    _inherit = "ir.http"

    @classmethod
    def _auth_method_api_key(cls):
        headers = request.httprequest.environ
        api_key = headers.get("HTTP_API_KEY")
        if api_key:
            request.update_env(user=1)
            auth_api_key = request.env["auth.api.key"]._retrieve_api_key(api_key)
            if auth_api_key:
                request._env = None
                request.update_env(user=auth_api_key.user_id.id)
                request.auth_api_key = api_key
                request.auth_api_key_id = auth_api_key.id
                return True
        _logger.error("Wrong HTTP_API_KEY, access denied")
        raise Unauthorized()

Defining _auth_method_api_key registers a new value for the auth= argument of @http.route. That is the whole trick, and it is still the module’s reason to exist today:

@http.route("/my/custom/endpoint", auth="api_key", type="json")
def my_endpoint(self):
    # request now runs as the service user mapped to the presented key
    ...

The data model behind it was — and is — deliberately simple. A key is not a user’s personal credential; it is a named record that maps an opaque string to a service user:

# server-auth/auth_api_key/models/auth_api_key.py
class AuthApiKey(models.Model):
    _name = "auth.api.key"

    name = fields.Char(required=True)
    key = fields.Char(required=True, help="The API key. Enter a dummy value ... "
                      "if it is obtained from the server environment configuration.")
    user_id = fields.Many2one("res.users", string="User", required=True,
                              help="The user used to process the requests "
                                   "authenticated by the api key")

Three essential fields (there is also a computed active): a name, the key string, and the user the request runs as. This2 became the auth primitive for OCA’s REST framework (base_rest, and later fastapi). Remember this shape — the whole rest of the story is core slowly building its own key system next to it, without ever adopting this one.


2020 (Odoo 14): core finally ships API keys — but narrow

Odoo 14 introduced native res.users.apikeys (verified: the model is absent on branch 13.0 and present on 14.0 — GitHub, checked 2026-07-17)9. This is the system the previous post dissected: a key is a real user’s own credential, stored hashed, carrying a scope, minted self-service from Preferences → Account Security. One documented use is two-factor auth: the core hook _rpc_api_keys_only exists “to be overridden if RPC access needs to be restricted to API keys, e.g. for 2FA” (its own docstring) — a 2FA user cannot send a plain password over RPC, so they present a key instead.

Crucially, native keys authenticate Odoo’s own external API — RPC then, /json/2 now. Not the interactive web login: in _check_credentials the API-key branch is only reached on the non-interactive path (if not interactive:), so you cannot log into the web client with a key. And in Odoo 14 there was still no hook for a controller you wrote. So the moment native keys landed, they did not make auth_api_key redundant — they overlapped on “authenticate an RPC call,” but left the original use case (guard my custom endpoint) exactly where it was.

That was the crux of the comparison for Odoo 14 through 17 — a design boundary, not an oversight. (Hold that thought: Odoo 18 moves the boundary, and we get there below.)

Native res.users.apikeys (2020) OCA auth.api.key (2018)
A key is… a credential owned by a user a record an admin maps to any user
Storage hashed (shown once) plaintext in DB by default
Authenticates (v14–17) Odoo’s own external API (RPC) — not the web login, not your controllers any @route(auth="api_key") you define
Key value always a server-generated 160-bit random, shown once an admin can set an arbitrary value (even from env config)
Provisioning self-service UI, or programmatic generate() — which needs an existing native key (the bootstrap gap) admin creates the record; no pre-existing key needed
Expiration / rotation built-in expiration field; standard users capped at 3 months; generate()/revoke exist no expiration field on the base model; rotation is your own process
Runs as the owning user (ACL/record-rules apply) whatever user_id you assign (ACL/record-rules apply)

Note what the identity difference really is. Both keys ultimately resolve to a res.users — OCA’s auth.api.key requires a user_id, a native key can belong to a login-less “bot” user (the pattern the previous post quoted from Odoo’s docs), and both run under that user’s ACL and record rules. So this is not “person vs service account.” Two real differences remain: (1) provisioning — OCA lets an admin assign an arbitrary key value to any user with no pre-existing key, whereas native always generates a random value and its programmatic generate() requires an existing native key (leaving the first-key bootstrap unsolved — the subject of the previous post); (2) lifecycle — native keys have built-in expiration and revocation; the OCA base model has neither.

The line that was clean for v14–17: native keys authenticate Odoo’s API; OCA keys authenticate your API. True then. Odoo 18 is where it stops being true — which is the interesting part of the story.


2021 (Odoo 14): OCA stops filling a gap and starts building patterns

By 2021 the OCA modules were no longer just “the thing core is missing.” Camptocamp added capabilities core still does not have.

auth_api_key_group3 — pluggable scoping. A native key carries a single scope string, and /json/2 accepts an unscoped key or an rpc-scoped one (the SQL match is scope IS NULL OR scope = 'rpc'); there is no way to say “this key may hit endpoint A but not B.” OCA moved scoping out of the key into an extensible concept:

# server-auth/auth_api_key_group/models/auth_api_key_group.py
class AuthApiKeyGroup(models.Model):
    _name = "auth.api.key.group"

    name = fields.Char(required=True)
    code = fields.Char(required=True)
    auth_api_key_ids = fields.Many2many("auth.api.key", ...)

The manifest is refreshingly honest about what it does alone:

Grouping per se does nothing. This feature is supposed to be used by other modules to limit access to services or records based on groups of keys.

It is a hook, not a feature — consumers decide what a group grants.

auth_api_key_server_env4 — keys out of the database. This one answers a real operational pain: by default the OCA key sits in the key column as plaintext. server_env moves the value into the server environment configuration instead:

# server-auth/auth_api_key_server_env/models/auth_api_key.py
class AuthApiKey(models.Model):
    _inherit = ["auth.api.key", "server.env.techname.mixin", "server.env.mixin"]

    def _server_env_section_name(self):
        # section in the config file: [api_key_<name>]
        return f"api_key_{getattr(self, self._server_env_section_name_field)}"

    @property
    def _server_env_fields(self):
        base_fields = super()._server_env_fields
        return {"key": {}, **base_fields}

You put a dummy value in the DB and the real key lives in a [api_key_<name>] section of your environment config — so, per the manifest, you “avoid mixing your keys between your various environments when restoring databases.” Restore production into staging and the staging key stays whatever staging’s config says. That is 12-factor secret handling; native keys have no equivalent, because a native key hash always travels inside the database dump.

(base_rest_auth_api_key landed the same year, wiring auth_api_key into base_rest’s OpenAPI security scheme — the REST framework consuming the primitive.)

ℹ️ At the time of writing, auth_api_key_server_env has not been migrated past 18.0 — the 19.0 server-auth branch5 ships only auth_api_key and auth_api_key_group (auth_api_key_native_generate is still an open PR6). The code below is from the 18.0 branch. If you need it on 19, it is a migration, not an install.


2024 (early, Odoo 17): api-key auth becomes a first-class FastAPI dependency

When OCA’s fastapi module made FastAPI a way to build Odoo endpoints, fastapi_auth_api_key (first commit caaa7fcb7, 2024-02-28, on the 17.0 branch — and, at the time of writing, not yet migrated to 19.0) turned auth.api.key into a FastAPI dependency and used the group from 2021 to gate each endpoint:

# rest-framework/fastapi_auth_api_key/dependencies.py
def authenticated_auth_api_key(
    key: Annotated[str, Depends(APIKeyHeader(name=HTTP_API_KEY_HEADER))],
    env: Annotated[Environment, Depends(odoo_env)],
    endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)],
) -> AuthApiKey:
    ...
    admin_env = Environment(env.cr, SUPERUSER_ID, {})
    auth_api_key = admin_env["auth.api.key"]._retrieve_api_key(key)   # 401 if unknown
    # Ensure the key is authorized for THIS endpoint via its group:
    if (endpoint.sudo().auth_api_key_group_id
            and auth_api_key not in endpoint.sudo().auth_api_key_group_id.auth_api_key_ids):
        raise HTTPException(status_code=401, detail=env._("Unauthorized"))
    return auth_api_key

An endpoint declares which key group it accepts; the dependency enforces it. This is the payoff of “shared primitive”: one auth.api.key record works as a header credential and as a first-class FastAPI dependency, with per-endpoint group gating, without a second key system. A native bearer key can authenticate such a request, and native keys do carry a scope field — but the built-in auth="bearer" route layer only checks scope='rpc' and offers no per-endpoint configuration, so the “this key may hit endpoint A but not B” gating has no configurable equivalent there.


2024 (Sep, Odoo 18): core moves the boundary itself

This is where the story turns — and, importantly, it turns a year before /json/2, which is easy to get wrong. In Odoo 18, core added a generic auth="bearer" method to ir.http (commit e6d5945110, "[IMP] core: bearer authorization header", 2024-09-16; present on branches 18.011 and 19.012, absent on 17.0 and earlier — GitHub, checked 2026-07-17):

# odoo/addons/base/models/ir_http.py  (core, 18.0+)
@classmethod
def _auth_method_bearer(cls):
    ...
    if token := get_http_authorization_bearer_token():
        # 'rpc' scope does not really exist, we basically require a global key (scope NULL)
        uid = request.env['res.users.apikeys']._check_credentials(scope='rpc', key=token)
        if not uid:
            raise Unauthorized("Invalid apikey", www_authenticate=WWWAuthenticate('bearer'))
        ...

That is the same extension mechanism auth_api_key pioneered in 2018 — a value for the auth= argument of @http.route — except now it is in core and backed by native keys. Any module on 18.0+ can write:

@http.route("/my/custom/endpoint", auth="bearer", type="http")
def my_endpoint(self):
    # authenticated by a native res.users.apikeys bearer token, no OCA module
    ...

So the boundary that held from Odoo 14 to 17 — native authenticates Odoo’s API, OCA authenticates your APIerodes in Odoo 18. Native keys can now guard a controller you wrote. The single biggest reason auth_api_key existed is now shipped in the box.

That does not make the OCA stack pointless — it narrows what it uniquely offers to four things auth="bearer" still does not give you:

  • Provisioning ergonomics — an admin can create an OCA key for any user and set its value from environment config, with no pre-existing key. Native programmatic generate() always mints a random value and requires an existing native key.
  • Group-based endpoint gatingauth_api_key_group + the FastAPI/base_rest consumers let one key be authorized for endpoint A but not B. Native auth="bearer" is all-or-nothing at the route auth layer (any valid global key passes; finer control means writing your own ACL/record-rule checks inside the handler).
  • Secrets out of the databaseauth_api_key_server_env keeps the key value out of the DB dump. Native key hashes always travel inside the dump.
  • Bootstrap — as the previous post showed, core still has no unattended password→first-key path.

(The reverse is also true: native keys have built-in expiration and revocation that the OCA base auth.api.key model lacks — a point in native’s favor, covered in the trade-off below.)

2025 (Odoo 19): /json/2 makes the bearer key the primary external auth

Odoo 19 shipped /json/2 (the addons/rpc/controllers/json2.py13 controller — new in 19.0, absent on 18.0) and made the bearer API key the primary external transport auth, deprecating the password-based XML-RPC/JSON-RPC APIs in favor of JSON-2 (they are deprecated, not yet removed — Odoo’s docs schedule removal for Odoo 22; see the migration post). The auth="bearer" plumbing was already there from 18; 19 is when native keys become the default, front-door way to reach Odoo’s own API.

So by 2025 OCA is no longer the way, nor even the only way, to key-protect your own endpoint. It is the way you reach for when you need one of the four things above.

Then comes the clearest sign of the shifted relationship: auth_api_key_native_generate (the module from the previous post). It targets the same server-auth repo (still under review as PR #9706 at the time of writing), but it does not add another key system. It mints native res.users.apikeys programmatically — it closes the one bootstrap gap core left open, and hands you a core key to use with /json/2.

That is the whole arc in one module. In 2018 OCA existed because core had nothing. By 2026 OCA writes code whose job is to feed core’s own key system. The two are no longer rivals; they compose.


The honest trade-off you must not skip: storage

Being code-grounded means naming where OCA is weaker, not only where it is more flexible.

Native res.users.apikeys stores a hash. Steal the database and you cannot recover the keys. OCA’s auth.api.key, by default, stores the key as plaintext in the key column, comparing with a timing-safe consteq:

# server-auth/auth_api_key/models/auth_api_key.py
@tools.ormcache("key")
def _retrieve_api_key_id(self, key):
    if not self.env.user.has_group("base.group_system"):
        raise AccessError(...)
    for api_key in self.search([], limit=None):
        if api_key.key and consteq(key, api_key.key):   # constant-time compare
            return api_key.id
    raise ValidationError(...)

consteq protects the comparison against timing attacks, but the value at rest is readable — anyone with a dump, a backup, or a SELECT sees every live key. The mitigation is auth_api_key_server_env: keep the key value out of the DB. It is strongly recommended whenever operational secret management is available — but note two caveats. First, it does not magically make a secret safe: the key still lives in plaintext somewhere (config file, environment variable, or a mounted secret), so it wants the usual handling — a real secret store (Vault, Kubernetes/Docker secrets), tight file permissions, and rotation. Second, as noted above, auth_api_key_server_env is not yet migrated to 19.0. (Note too that retrieval scans all keys with search([]) and consteq — fine for a bounded set of integration keys, not for per-user auth at scale. That reinforces the intended use.)

There is a second operational gap that a storage comparison alone misses: lifecycle. Native res.users.apikeys has a built-in expiration_date, a documented 3-month cap for standard users, and native generate/revoke. The OCA base auth.api.key model has no expiration field at all — a key is valid until someone deletes the record, and rotation is entirely your own process. For anything long-lived, that is a stronger argument for native than the hashing is.

⚠️ Default OCA auth.api.key keeps the key in plaintext in the key column and has no built-in expiration. For a sensitive deployment, pair it with auth_api_key_server_env (backed by a proper secret store) and add your own rotation/expiry process.


So which one do you use?

They are not ranked; they fit different jobs — and after the timeline, the split should feel natural.

flowchart TD
    A["What are you protecting?"] --> B["Odoo's own models over /json/2 or RPC"]
    A --> C["A custom endpoint you wrote"]
    B --> N1["Native res.users.apikeys"]
    C --> D{"Odoo 18 or newer?"}
    D -- "No (14-17)" --> O1["OCA auth_api_key"]
    D -- Yes --> E{"Need group gating, admin/env<br/>provisioning, or secrets out of the DB?"}
    E -- No --> N2["Native auth=bearer"]
    E -- Yes --> O2["OCA auth_api_key<br/>+ group / server_env"]

Same decision as a lookup table, with the edge cases:

Your situation Reach for
Calling Odoo’s own models over /json/2 (mobile app, RPC client, ETL) Native res.users.apikeys — hashed, user-owned, the transport already speaks it
Guarding a custom endpoint on Odoo 18+, simple all-or-nothing check Native auth="bearer" — no extra module needed
Guarding a custom endpoint on Odoo ≤17 OCA auth_api_key — core had no auth="bearer" yet
Per-endpoint / per-key gating (“this key may hit A but not B”) OCA + auth_api_key_group (+ FastAPI / base_rest consumers)
Keys provisioned by an admin, value from environment config OCA auth_api_key (+ auth_api_key_server_env)
Secrets that must stay out of the database dump OCA + auth_api_key_server_env (backed by a real secret store)
Self-service ownership, built-in expiration and native revocation lifecycle Native

For Odoo ≤17, the old rule is the fastest guide: native authenticates Odoo’s API, OCA authenticates yours. From Odoo 18 that line is gone — auth="bearer" lets native keys guard your endpoints too — so the question shifts from “whose endpoint is it?” to “do I need group gating, admin/env provisioning, or secrets out of the DB?” If yes, OCA; if no, native alone is enough. Either way, thanks to auth_api_key_native_generate you can run both on the same instance.

🔒 Security recommendation. Default to native keys — hashed at rest, with built-in expiration and revocation. Adopt the OCA stack only for one of the four gaps above. When you do, auth_api_key_server_env is strongly recommended (never leave a live key plaintext in the DB) — backed by a real secret store (Vault, Kubernetes/Docker secrets), tight file permissions, and an explicit rotation/expiry process, since the base model gives you none. For a greenfield Odoo 18+/19 project, start native-first and add OCA modules only where a concrete gap demands it.


Takeaways

  • OCA got there first. auth_api_key (2018, Akretion 2017 roots, branch 10.0) predates native res.users.apikeys (2020) — it was never an alternative to core, it filled a void core did not fill for two more years.
  • Core caught up in two waves. Odoo 14 took the RPC case; Odoo 18 added a generic auth="bearer" (native-key-backed) that lets native keys guard custom controllers too — the boundary that justified OCA. Odoo 19’s /json/2 then made the bearer key the primary external auth.
  • What’s left for OCA is narrower and real. Per-endpoint group gating, admin/env-based provisioning, secrets out of the DB dump, and the password→first-key bootstrap. Native still does none of these.
  • The relationship flipped. With auth_api_key_native_generate, OCA now feeds core’s key system instead of replacing it. Rivals became composable.
  • Mind storage and lifecycle. Default OCA keys are plaintext in the DB and have no expiration; use auth_api_key_server_env (backed by a real secret store) plus your own rotation. Native is hashed and carries built-in expiration/revocation out of the box.

Ready to get the most out of Odoo?

Whether you are starting a new implementation, upgrading from an older version, or optimizing your current setup — our Odoo-first team is here to help.