diff --git a/privacyidea/lib/config.py b/privacyidea/lib/config.py
index 819100544e4ba615c8011c99eaf85228a7e06bf6..10a948078a16e42f99cacbf69518da934a4b4d10 100644
--- a/privacyidea/lib/config.py
+++ b/privacyidea/lib/config.py
@@ -699,6 +699,7 @@ def get_token_list():
     """
     module_list = set()
 
+    module_list.add("privacyidea.lib.tokens.backupcodetoken")
     module_list.add("privacyidea.lib.tokens.daplugtoken")
     module_list.add("privacyidea.lib.tokens.hotptoken")
     module_list.add("privacyidea.lib.tokens.motptoken")
diff --git a/privacyidea/lib/tokens/backupcodetoken.py b/privacyidea/lib/tokens/backupcodetoken.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d64dfbf7895e995776c0b65a532ea5fbb90bdec
--- /dev/null
+++ b/privacyidea/lib/tokens/backupcodetoken.py
@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+#
+#  2022-09-22 Peter Bolha <485456@mail.muni.cz>
+#             BUC token with to be randomly used BUC values
+#
+#  (c) 2022 Peter Bolha - 485456@mail.muni.cz
+#
+# This code is free software; you can redistribute it and/or
+# modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+# License as published by the Free Software Foundation; either
+# version 3 of the License, or any later version.
+#
+# This code is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU AFFERO GENERAL PUBLIC LICENSE for more details.
+#
+# You should have received a copy of the GNU Affero General Public
+# License along with this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+"""
+This file contains the definition of the backupcode token class
+It depends on the DB model, and the lib.tokenclass.
+"""
+
+import ast
+import logging
+import secrets
+import string
+
+from privacyidea.lib import _
+from privacyidea.lib.decorators import check_token_locked
+from privacyidea.lib.log import log_with
+from privacyidea.lib.policy import ACTION, GROUP, SCOPE
+from privacyidea.lib.tokenclass import TokenClass
+
+log = logging.getLogger(__name__)
+
+# number of generated tokens per method call
+DEFAULT_COUNT = 10
+# length of each token
+DEFAULT_LENGTH = 16
+CHARACTER_POOL = list(string.digits) + list(string.ascii_letters)
+SECRET_GENERATOR = secrets.SystemRandom()
+
+
+class BACKUPCODEACTION(object):
+    BACKUPCODETOKEN_COUNT = "backupcodetoken_count"
+    BACKUPCODETOKEN_LENGTH = "backupcodetoken_length"
+
+
+class BackupCodeTokenClass(TokenClass):
+    """
+    The BUC token allows printing out valid OTP values.
+    This sheet of paper can be used to authenticate and strike out the used
+    OTP values. It works akin to TAN token (OTPs can be used in random order).
+    The difference is that the number and length of the generated OTPs can be
+    configured as well as the character pool for code generation. The default
+    settings: 10 codes, 16 characters long, generated from alphanumeric
+    charset. (a-zA-Z0-9)
+    """
+
+    @log_with(log)
+    def __init__(self, db_token):
+        """
+        This creates a new Backup Code Token object from a DB token object.
+
+        :param db_token: instance of the orm db object
+        :type db_token:  orm object
+        """
+        TokenClass.__init__(self, db_token)
+        self.set_type(u"backupcode")
+        self.hKeyRequired = False
+
+    @staticmethod
+    def get_class_type():
+        """
+        Return the token type shortname.
+
+        :return: 'backupcode'
+        :rtype: string
+        """
+        return "backupcode"
+
+    @staticmethod
+    def get_class_prefix():
+        """
+        Return the prefix, that is used as a prefix for the serial.
+        numbers
+        :return: BUC
+        """
+        return "BUC"
+
+    @staticmethod
+    @log_with(log)
+    def get_class_info(key=None, ret='all'):
+        """
+        Returns a subtree of the token definition
+
+        :param key: subsection identifier
+        :type key: string
+        :param ret: default return value, if nothing is found
+        :type ret: user defined
+        :return: subsection if key exists or user defined
+        :rtype: dict or scalar
+        """
+        res = {'type': 'backupcode',
+               'title': 'Backup Code Token',
+               'description': 'BUC: Backup codes generated by user.',
+               'init': {},
+               'config': {},
+               'user': ['enroll'],
+               'ui_enroll': ["admin", "user"],
+               'policy': {
+                   SCOPE.ENROLL: {
+                       BACKUPCODEACTION.BACKUPCODETOKEN_COUNT: {
+                           "type": "int",
+                           "desc": _("The number of OTP values, which are "
+                                     "generated by user.")
+                       },
+                       BACKUPCODEACTION.BACKUPCODETOKEN_LENGTH: {
+                           "type": "int",
+                           "desc": _("Length of each generated token.")
+                       },
+                       ACTION.MAXTOKENUSER: {
+                           'type': 'int',
+                           'desc': _(
+                               "The user may only have this maximum number "
+                               "of backup code tokens assigned."),
+                           'group': GROUP.TOKEN
+                       },
+                       ACTION.MAXACTIVETOKENUSER: {
+                           'type': 'int',
+                           'desc': _(
+                               "The user may only have this maximum number "
+                               "of active backup code tokens assigned."),
+                           'group': GROUP.TOKEN
+                       }
+
+                   }
+               }
+               }
+
+        if key:
+            ret = res.get(key, {})
+        else:
+            if ret == 'all':
+                ret = res
+        return ret
+
+    def generate_codes(self, length, count):
+        """
+        Generates a list with <count> number of alphanumeric codes with
+        length of <length> characters picked from CHARACTER_POOL.
+
+        :param length: length of each generated token in characters
+        :type length: int
+        :param count: total number of tokens to generate
+        :type count: int
+        :return: list of tokens generated with preconfigured length, number and
+                 character set
+        :rtype: list
+        """
+        return ["".join(SECRET_GENERATOR.choices(CHARACTER_POOL, k=length)) for
+                _ in range(count)]
+
+    def check_otp(self, otpval, counter=None, window=None, options=None):
+        """
+        Check if the given OTP value is valid for this token.
+
+        :param anOtpVal: the to be verified otpvalue
+        :type anOtpVal: string
+        :param counter: the counter state, that should be verified
+        :type counter: int
+        :param window: the counter +window, which should be checked
+        :type window: int
+        :param options: the dict, which could contain token specific
+        info
+        :type options: dict
+        :return: the counter state or -1
+        :rtype: int
+        """
+        token_info = self.get_tokeninfo()
+        all_user_codes = ast.literal_eval(token_info.get("otps", []))
+        used_codes = ast.literal_eval(token_info.get("used_otps", []))
+
+        if otpval in used_codes:
+            return -1
+
+        if otpval in all_user_codes:
+            used_codes.append(otpval)
+            token_info["used_otps"] = used_codes
+            self.set_tokeninfo(token_info)
+            return 1
+
+        return -1
+
+    @check_token_locked
+    def authenticate(self, passw, user=None, options=None):
+        """
+        High level interface which covers the check_pin and check_otp
+        This is the method that verifies single shot authentication
+        like they are done with push button tokens.
+
+        It is automatically called for OTPs longer than 6 characters.
+        Passw can be either a plain OTP token or a pin concatenated with an
+        OTP.
+        Pin can be either prepended in front of the OTP (default) - like
+        pinOTP or
+        it can be appended after the OTP - like OTPpin. Appending or prepending
+        a pin can be configured in the TokenClass config.
+
+        If the authentication succeeds an OTP counter needs to be
+        increased,
+        i.e. the OTP value that was used for this authentication is
+        invalidated!
+
+        :param passw: the password which could be pin+otp value
+        :type passw: string
+        :param user: The authenticating user
+        :type user: User object
+        :param options: dictionary of additional request parameters
+        :type options: dict
+
+        :return: returns tuple of
+
+            1. true or false for the pin match,
+            2. the otpcounter (int) and the
+            3. reply (dict) that will be added as additional
+            information in
+                the JSON response of ``/validate/check``.
+
+        :rtype: tuple(bool, int, dict)
+        """
+        is_pin_correct = False
+        otp_counter = -1
+        reply = None
+
+        (is_split_successfully, pin, otpval) = self.split_pin_pass(passw,
+                                                                   user=user,
+                                                                   options=options)
+        if is_split_successfully:
+            is_pin_correct = self.check_pin(pin, user=user, options=options)
+            if is_pin_correct:
+                otp_counter = self.check_otp(otpval, options=options)
+
+        return is_pin_correct, otp_counter, reply
+
+    def update(self, param, reset_failcount=True):
+        """
+        Update the token object
+
+        :param param: a dictionary with different params
+        like keysize,description, genkey, otpkey, pin
+        :type: param: dict
+        """
+        token_count = int(param.get("backupcodetoken_count", DEFAULT_COUNT))
+        token_length = int(param.get("backupcodetoken_length", DEFAULT_LENGTH))
+        param["otplen"] = token_length
+        TokenClass.update(self, param, reset_failcount=reset_failcount)
+        backup_codes = self.generate_codes(length=token_length,
+                                           count=token_count)
+        self.add_init_details("otps", backup_codes)
+        self.add_tokeninfo("otps", backup_codes)
+        self.add_tokeninfo("used_otps", [])
diff --git a/privacyidea/static/components/token/views/token.enroll.backupcode.html b/privacyidea/static/components/token/views/token.enroll.backupcode.html
new file mode 100644
index 0000000000000000000000000000000000000000..0fcc96e502ba9b231e0d65629bf7b1c398f1b3c9
--- /dev/null
+++ b/privacyidea/static/components/token/views/token.enroll.backupcode.html
@@ -0,0 +1,5 @@
+<p class="help-block" translate>
+    The TAN token will let you print a list of OTP values.
+    These OTP values can be used to authenticate. The values can be used in an
+    arbitrary order.
+</p>
\ No newline at end of file
diff --git a/privacyidea/static/components/token/views/token.enrolled.backupcode.html b/privacyidea/static/components/token/views/token.enrolled.backupcode.html
new file mode 100644
index 0000000000000000000000000000000000000000..7c1889a48fbb1db5ed3e7060a817ea87ef35bcdf
--- /dev/null
+++ b/privacyidea/static/components/token/views/token.enrolled.backupcode.html
@@ -0,0 +1,58 @@
+<div class="row">
+    <div class="col-sm-12">
+        <uib-accordion close-others="oneAtATime">
+            <div uib-accordion-group
+                 class="panel-default"
+                 heading="{{ 'The OTP values'|translate }}">
+                <div id="paperOtpTable">
+                    <div ng-include="instanceUrl+'/'+piCustomization+
+                    '/views/includes/token.enrolled.tan.top.html'"></div>
+                    <div class="table-responsive">
+                        <table class="table table-bordered table-striped
+                                  tantoken">
+                            <thead>
+                            <tr>
+                                <th translate>#</th>
+                                <th>OTP</th>
+                                <th translate>#</th>
+                                <th>OTP</th>
+                                <th translate>#</th>
+                                <th>OTP</th>
+                                <th translate>#</th>
+                                <th>OTP</th>
+                                <th translate>#</th>
+                                <th>OTP</th>
+                            </tr>
+                            </thead>
+                            <tbody>
+                            <tr ng-repeat="key in otp_rows">
+                                <td>{{ $index }}</td>
+                                <td>{{ enrolledToken.otps[$index] }}</td>
+                                <td>{{ $index+(1*otp_row_count) }}</td>
+                                <td>{{ enrolledToken.otps[$index+
+                                        (1*otp_row_count)] }}</td>
+                                <td>{{ $index+(2*otp_row_count) }}</td>
+                                <td>{{ enrolledToken.otps[$index+
+                                        (2*otp_row_count)] }}</td>
+                                <td>{{ $index+(3*otp_row_count) }}</td>
+                                <td>{{ enrolledToken.otps[$index+
+                                        (3*otp_row_count)] }}</td>
+                                <td>{{ $index+(4*otp_row_count) }}</td>
+                                <td>{{ enrolledToken.otps[$index+
+                                        (4*otp_row_count)] }}</td>
+                            </tr>
+                            </tbody>
+                        </table>
+                    </div>
+                    <div ng-include="instanceUrl+'/'+piCustomization+
+                    '/views/includes/token.enrolled.tan.bottom.html'"></div>
+                </div>
+            </div>
+        </uib-accordion>
+    </div>
+</div>
+<button class="btn-default btn"
+    ng-click="printOtp()">
+    <span class="glyphicon glyphicon-print"></span>
+    <span translate>Print the OTP list</span>
+</button>
\ No newline at end of file
diff --git a/tests/test_lib_tokens_backupcode.py b/tests/test_lib_tokens_backupcode.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e3103befc018f93ce4459f2719e87e761e8e693
--- /dev/null
+++ b/tests/test_lib_tokens_backupcode.py
@@ -0,0 +1,150 @@
+"""
+This test file tests the lib.tokens.backupcodetoken
+This depends on lib.tokenclass
+"""
+import ast
+
+from privacyidea.lib.token import init_token
+from privacyidea.lib.tokens.backupcodetoken import BackupCodeTokenClass
+from privacyidea.lib.tokens.backupcodetoken import DEFAULT_COUNT
+from privacyidea.lib.tokens.backupcodetoken import DEFAULT_LENGTH
+from privacyidea.models import Token
+from .base import MyTestCase
+
+"""
+ Tokens need to be of configured length in order for pin-parsing to work.
+ Pin and otp value are passed as a single string which is then split based on
+ the length of the OTP.
+"""
+VALID_OTP_1 = "a" * DEFAULT_LENGTH
+VALID_OTP_2 = "b" * DEFAULT_LENGTH
+INVALID_OTP = "n" * DEFAULT_LENGTH
+
+VALID_PIN = "valid_pin"
+INVALID_PIN = "invalid_pin"
+
+SUCCESS = 1
+FAILURE = -1
+
+
+class BackupCodeTokenTestCase(MyTestCase):
+    serial1 = "ser1"
+
+    def get_fresh_token(self, set_pin=False):
+        token = init_token({"type": "backupcode"})
+        token.set_tokeninfo(
+            {"otps": f"['{VALID_OTP_1}', '{VALID_OTP_2}']", "used_otps": "[]"})
+
+        if set_pin:
+            token.set_pin(VALID_PIN)
+
+        return token
+
+    def test_01_create_token(self):
+        db_token = Token(self.serial1, tokentype="backupcode")
+        db_token.save()
+        token = BackupCodeTokenClass(db_token)
+        token.update({})
+
+        self.assertEqual(token.token.serial, self.serial1)
+        self.assertEqual(token.token.tokentype, "backupcode")
+        self.assertEqual(token.type, "backupcode")
+
+        class_prefix = token.get_class_prefix()
+
+        self.assertEqual(class_prefix, "BUC")
+        self.assertEqual(token.get_class_type(), "backupcode")
+
+    def test_02_class_methods(self):
+        db_token = Token.query.filter(Token.serial == self.serial1).first()
+        token = BackupCodeTokenClass(db_token)
+
+        info = token.get_class_info()
+        self.assertEqual(info.get("title"), "Backup Code Token")
+
+        info = token.get_class_info("title")
+        self.assertEqual(info, "Backup Code Token")
+
+    def test_03_get_init_details(self):
+        db_token = Token.query.filter(Token.serial == self.serial1).first()
+        token = BackupCodeTokenClass(db_token)
+        token.update({})
+
+        # make sure OTPs were created
+        init_detail = token.get_init_detail()
+        self.assertTrue("otps" in init_detail)
+
+        # make sure there's correct (configured) number of generated OTPs
+        frontend_otps = init_detail.get("otps")
+        backend_otps = ast.literal_eval(token.get_tokeninfo("otps"))
+        self.assertEqual(frontend_otps, backend_otps)
+        self.assertTrue(len(frontend_otps) == DEFAULT_COUNT)
+
+        used_otps = ast.literal_eval(token.get_tokeninfo("used_otps"))
+        self.assertEqual(used_otps, [])
+
+        # make sure the generated OTPs are of correct (configured) length
+        for otp in frontend_otps:
+            self.assertTrue(len(otp) == DEFAULT_LENGTH)
+
+    def test_04_check_unused_existent_otp(self):
+        token = self.get_fresh_token()
+        valid_otp_check_result = token.check_otp(VALID_OTP_1)
+        used_otps = ast.literal_eval(token.get_tokeninfo("used_otps"))
+
+        self.assertEquals(valid_otp_check_result, SUCCESS)
+        self.assertEquals(used_otps, [VALID_OTP_1])
+
+    def test_05_check_used_existent_otp(self):
+        token = self.get_fresh_token()
+
+        self.assertEquals(token.check_otp(VALID_OTP_1), SUCCESS)
+        self.assertEquals(token.check_otp(VALID_OTP_1), FAILURE)
+
+    def test_06_check_non_existent_otp(self):
+        token = self.get_fresh_token()
+        all_otps = ast.literal_eval(token.get_tokeninfo("otps"))
+
+        self.assertTrue(INVALID_OTP not in all_otps)
+        self.assertEquals(token.check_otp(INVALID_OTP), FAILURE)
+
+    def test_07_authenticate_with_correct_pin_and_correct_otp(self):
+        token = self.get_fresh_token(set_pin=True)
+
+        auth_info = VALID_PIN + VALID_OTP_1
+        is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info)
+
+        self.assertTrue(is_pin_correct)
+        self.assertEqual(otp_auth_result, SUCCESS)
+
+    def test_08_authenticate_with_incorrect_pin_and_correct_otp(self):
+        token = self.get_fresh_token(set_pin=True)
+
+        auth_info = INVALID_PIN + VALID_OTP_1
+        is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info)
+
+        self.assertFalse(is_pin_correct)
+        self.assertEqual(otp_auth_result, FAILURE)
+
+    def test_08_authenticate_with_correct_pin_and_incorrect_otp(self):
+        token = self.get_fresh_token(set_pin=True)
+
+        auth_info = VALID_PIN + INVALID_OTP
+        is_pin_correct, otp_auth_result, _ = token.authenticate(auth_info)
+
+        self.assertTrue(is_pin_correct)
+        self.assertEqual(otp_auth_result, FAILURE)
+
+    def test_08_authenticate_without_pin_correct_otp(self):
+        token = self.get_fresh_token()
+
+        is_pin_correct, otp_auth_result, _ = token.authenticate(VALID_OTP_1)
+        self.assertTrue(is_pin_correct)
+        self.assertEqual(otp_auth_result, SUCCESS)
+
+    def test_08_authenticate_without_pin_incorrect_otp(self):
+        token = self.get_fresh_token()
+
+        is_pin_correct, otp_auth_result, _ = token.authenticate(INVALID_OTP)
+        self.assertTrue(is_pin_correct)
+        self.assertEqual(otp_auth_result, FAILURE)