diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py
index 017aef30313caa181d7b76be9c248f59a88e6bc3..f545e16ee608f6948fee1fae6ea80ff5d1e5d961 100644
--- a/addons/portal/__manifest__.py
+++ b/addons/portal/__manifest__.py
@@ -21,6 +21,7 @@ a dependency towards website editing and customization capabilities.""",
         'data/mail_template_data.xml',
         'data/mail_templates.xml',
         'views/portal_templates.xml',
+        'views/res_config_settings_views.xml',
         'wizard/portal_share_views.xml',
         'wizard/portal_wizard_views.xml',
     ],
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py
index 410e78500f0cfc6d8bebc90a180a704add75bc2a..1db9192123fc111e9e3d8a7f680a039da149f58b 100644
--- a/addons/portal/controllers/portal.py
+++ b/addons/portal/controllers/portal.py
@@ -223,6 +223,7 @@ class CustomerPortal(Controller):
     def security(self, **post):
         values = self._prepare_portal_layout_values()
         values['get_error'] = get_error
+        values['allow_api_keys'] = bool(request.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'))
 
         if request.httprequest.method == 'POST':
             values.update(self._update_password(
diff --git a/addons/portal/models/__init__.py b/addons/portal/models/__init__.py
index 1c70d12386ab6ff6a1e805d817815db52994fa79..e4169356b07cc9e66fd42e21a8e5ae85302d074a 100644
--- a/addons/portal/models/__init__.py
+++ b/addons/portal/models/__init__.py
@@ -6,4 +6,6 @@ from . import ir_ui_view
 from . import mail_thread
 from . import mail_message
 from . import portal_mixin
+from . import res_config_settings
 from . import res_partner
+from . import res_users_apikeys_description
diff --git a/addons/portal/models/res_config_settings.py b/addons/portal/models/res_config_settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..e483cf8160460f8ab79127137ff9145478f5a3c2
--- /dev/null
+++ b/addons/portal/models/res_config_settings.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+    _inherit = 'res.config.settings'
+
+    portal_allow_api_keys = fields.Boolean(
+        string='Customer API Keys',
+        compute='_compute_portal_allow_api_keys',
+        inverse='_inverse_portal_allow_api_keys',
+    )
+
+    def _compute_portal_allow_api_keys(self):
+        for setting in self:
+            setting.portal_allow_api_keys = self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys')
+
+    def _inverse_portal_allow_api_keys(self):
+        self.env['ir.config_parameter'].sudo().set_param('portal.allow_api_keys', self.portal_allow_api_keys)
+
+    @api.model
+    def get_values(self):
+        res = super(ResConfigSettings, self).get_values()
+        res['portal_allow_api_keys'] = bool(self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'))
+        return res
diff --git a/addons/portal/models/res_users_apikeys_description.py b/addons/portal/models/res_users_apikeys_description.py
new file mode 100644
index 0000000000000000000000000000000000000000..2539da5140ff3ab76339617091d0090e21dcef06
--- /dev/null
+++ b/addons/portal/models/res_users_apikeys_description.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, _
+from odoo.exceptions import AccessError
+
+
+class APIKeyDescription(models.TransientModel):
+    _inherit = 'res.users.apikeys.description'
+
+    def check_access_make_key(self):
+        try:
+            return super().check_access_make_key()
+        except AccessError:
+            if self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'):
+                if self.user_has_groups('base.group_portal'):
+                    return
+                else:
+                    raise AccessError(_("Only internal and portal users can create API keys"))
+            raise
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
index ac6dac67a6e41b217a93255d566760bb742c3554..b2c2853b2d00dd795e1ce44c22e90779a127fd7c 100644
--- a/addons/portal/static/src/js/portal.js
+++ b/addons/portal/static/src/js/portal.js
@@ -5,6 +5,7 @@ var publicWidget = require('web.public.widget');
 const Dialog = require('web.Dialog');
 const {_t, qweb} = require('web.core');
 const ajax = require('web.ajax');
+const session = require('web.session');
 
 publicWidget.registry.portalDetails = publicWidget.Widget.extend({
     selector: '.o_portal_details',
@@ -155,6 +156,87 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
     },
 });
 
+publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
+    selector: '.o_portal_new_api_key',
+    events: {
+        click: '_onClick'
+    },
+
+    async _onClick(e){
+        e.preventDefault();
+        // This call is done just so it asks for the password confirmation before starting displaying the
+        // dialog forms, to mimic the behavior from the backend, in which it asks for the password before
+        // displaying the wizard.
+        // The result of the call is unused. But it's required to call a method with the decorator `@check_identity`
+        // in order to use `handleCheckIdentity`.
+        await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
+            model: 'res.users',
+            method: 'api_key_wizard',
+            args: [session.user_id],
+        }));
+        await ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb);
+        const self = this;
+        const d_description = new Dialog(self, {
+            title: _t('New API Key'),
+            $content: qweb.render('portal.keydescription'),
+            buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => {
+                var description = d_description.el.querySelector('[name="description"]').value;
+                var wizard_id = await this._rpc({
+                    model: 'res.users.apikeys.description',
+                    method: 'create',
+                    args: [{name: description}],
+                });
+                var res = await handleCheckIdentity(
+                    this.proxy('_rpc'),
+                    this._rpc({
+                        model: 'res.users.apikeys.description',
+                        method: 'make_key',
+                        args: [wizard_id],
+                    })
+                );
+                const d_show = new Dialog(self, {
+                    title: _t('API Key Ready'),
+                    $content: qweb.render('portal.keyshow', {key: res.context.default_key}),
+                    buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}],
+                });
+                d_show.on('closed', this, () => {
+                    window.location = window.location;
+                });
+                d_show.open();
+            }}, {text: _t('Discard'), close: true}],
+        });
+        d_description.opened(() => {
+            const input = d_description.el.querySelector('[name="description"]');
+            input.focus();
+            d_description.el.addEventListener('submit', (e) => {
+                e.preventDefault();
+                d_description.$footer.find('.btn-primary').click();
+            });
+        });
+        d_description.open();
+    }
+});
+
+publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
+    selector: '.o_portal_remove_api_key',
+    events: {
+        click: '_onClick'
+    },
+
+    async _onClick(e){
+        e.preventDefault();
+        await handleCheckIdentity(
+            this.proxy('_rpc'),
+            this._rpc({
+                model: 'res.users.apikeys',
+                method: 'remove',
+                args: [parseInt(this.target.id)]
+            })
+        );
+        window.location = window.location;
+    }
+});
+
 /**
  * Wraps an RPC call in a check for the result being an identity check action
  * descriptor. If no such result is found, just returns the wrapped promise's
diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml
index 2c19cfeaa0d6afc6d6a5fa801041574abe334e3e..290c1779e6196508f35c3ea47a8ab9b9108f1a57 100644
--- a/addons/portal/static/src/xml/portal_security.xml
+++ b/addons/portal/static/src/xml/portal_security.xml
@@ -10,4 +10,32 @@
             <a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
         </form>
     </t>
+    <t t-name="portal.keydescription">
+        <form string="Key Description">
+            <h3><strong>Name your key</strong></h3>
+            <p>Enter a description of and purpose for the key.</p>
+            <input type="text" class="form-control col-10 col-md-6" placeholder="What's this key for?"
+                name="description" required="required"/>
+            <p>
+                It is very important that this description be clear
+                and complete, <strong>it will be the only way to
+                identify the key once created</strong>.
+            </p>
+        </form>
+    </t>
+    <t t-name="portal.keyshow">
+        <div>
+            <h3><strong>Write down your key</strong></h3>
+            <p>
+                Here is your new API key, use it instead of a password for RPC access.
+                Your login is still necessary for interactive usage.
+            </p>
+            <p><code><span t-out="key"/></code></p>
+            <p class="alert alert-warning" role="alert">
+                <strong>Important:</strong>
+                The key cannot be retrieved later and provides <b>full access</b>
+                to your user account, it is very important to store it securely.
+            </p>
+        </div>
+    </t>
 </templates>
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 38bc229f14419e55e04eabfabe9481f162dd2934..0f6334d1fbb0fe99b3572b016f93b09ed862be64 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -495,6 +495,41 @@
                     <button type="submit" class="btn btn-danger">Change Password</button>
                 </form>
             </section>
+            <section t-if="debug and allow_api_keys">
+                <h3>
+                Developer API Keys
+                    <a href="https://www.odoo.com/documentation/15.0/developer/misc/api/odoo.html#api-keys" target="_blank">
+                        <i title="Documentation" class="fa fa-fw o_button_icon fa-info-circle"></i>
+                    </a>
+                </h3>
+                <div>
+                    <table class="table o_main_table">
+                        <thead>
+                            <tr>
+                                <th>Description</th>
+                                <th>Scope</th>
+                                <th>Added On</th>
+                                <th/>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            <t t-foreach="request.env.user.api_key_ids" t-as="key">
+                                <tr>
+                                    <td><span t-field="key.name"/></td>
+                                    <td><span t-field="key.scope"/></td>
+                                    <td><span t-field="key.create_date"/></td>
+                                    <td>
+                                        <i class="fa fa-trash text-danger o_portal_remove_api_key" type="button" t-att-id="key.id"/>
+                                    </td>
+                                </tr>
+                            </t>
+                        </tbody>
+                    </table>
+                </div>
+                <div>
+                    <button type="submit" class="btn btn-primary o_portal_new_api_key">New API Key</button>
+                </div>
+            </section>
         </div></t>
     </template>
 
diff --git a/addons/portal/views/res_config_settings_views.xml b/addons/portal/views/res_config_settings_views.xml
new file mode 100644
index 0000000000000000000000000000000000000000..51c394b28bf01021ba6e32eaea13fd1a6ac4459a
--- /dev/null
+++ b/addons/portal/views/res_config_settings_views.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+    <data>
+
+        <record id="res_config_settings_view_form" model="ir.ui.view">
+            <field name="name">res.config.settings.view.form.inherit.portal</field>
+            <field name="model">res.config.settings</field>
+            <field name="priority" eval="40"/>
+            <field name="inherit_id" ref="base.res_config_settings_view_form"/>
+            <field name="arch" type="xml">
+                <xpath expr="//button[@name=%(base.action_apikeys_admin)d]//ancestor::div[hasclass('o_setting_box')]" position="inside">
+                    <div groups="base.group_no_one">
+                        <div class="o_setting_left_pane">
+                            <field name="portal_allow_api_keys"/>
+                        </div>
+                        <div class="o_setting_right_pane">
+                            <label for="portal_allow_api_keys"/>
+                            <div class="text-muted">
+                                Let your customers create developer API keys
+                            </div>
+                        </div>
+                    </div>
+                </xpath>
+            </field>
+        </record>
+
+    </data>
+</odoo>
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 5b691798ff3b9646cca33fc232f58fa3f0ac6ceb..ac3dc45305f1579913b690066eda8ca68b50ee29 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -1797,8 +1797,7 @@ class APIKeyDescription(models.TransientModel):
     @check_identity
     def make_key(self):
         # only create keys for users who can delete their keys
-        if not self.user_has_groups('base.group_user'):
-            raise AccessError(_("Only internal users can create API keys"))
+        self.check_access_make_key()
 
         description = self.sudo()
         k = self.env['res.users.apikeys']._generate(None, self.sudo().name)
@@ -1815,6 +1814,10 @@ class APIKeyDescription(models.TransientModel):
             }
         }
 
+    def check_access_make_key(self):
+        if not self.user_has_groups('base.group_user'):
+            raise AccessError(_("Only internal users can create API keys"))
+
 class APIKeyShow(models.AbstractModel):
     _name = _description = 'res.users.apikeys.show'