diff --git a/scripts/.pylintrc b/scripts/.pylintrc
index cfd3fa7abc0a0fd319049a5cffaff8331b20cce6..8c60de6bd111c4674007b34cf0e1a75342c23bf1 100644
--- a/scripts/.pylintrc
+++ b/scripts/.pylintrc
@@ -480,7 +480,7 @@ ignore-comments=yes
 ignore-docstrings=yes
 
 # Ignore imports when computing similarities.
-ignore-imports=no
+ignore-imports=yes
 
 # Minimum lines number of a similarity.
 min-similarity-lines=4
diff --git a/soitool/coder.py b/soitool/coder.py
index 533e25995904ffd936170b028beb5071f8ce432d..c0423364f2a1454eda1c1d35340449506ffe6b89 100644
--- a/soitool/coder.py
+++ b/soitool/coder.py
@@ -1,11 +1,12 @@
-"""Generate codes."""
-import string
+"""Generate codes.
 
-# https://realpython.com/lessons/cryptographically-secure-random-data-python/
+Source: # https://realpython.com/lessons/cryptographically-secure-random-data-python/
+"""
+import string
 import secrets
 
 
-def get_code(code_length, mode="ascii"):
+def get_code(code_length, mode="ascii", space_interval=0):
     """
     Generate a single random code.
 
@@ -13,18 +14,19 @@ def get_code(code_length, mode="ascii"):
     ----------
     code_length : int
         The length of the code
-
     mode : string
-        'ascii' for letters (default), 'digits' for digits and 'combo'
-        for combination of letters and digits.
+        'ascii' for letters, 'digits' for digits and 'combo' for
+        combination of letters and digits, by default 'ascii'.
+    space_interval : int
+        Spaces will be inserted into code each interval for readability if not
+        0, by default 0.
 
     Return
     ------
     code : string
-        The code
+        The code.
     """
     code = ""
-    i = 0
 
     if mode == "ascii":
         characters = string.ascii_uppercase
@@ -37,14 +39,20 @@ def get_code(code_length, mode="ascii"):
             "Invalid value for argument 'mode': " "'{}'".format(mode)
         )
 
+    i = 0
     while i < code_length:
         letter = secrets.choice(characters)
         code += letter
         i += 1
+
+    # Add spaces to code if interval is given
+    if space_interval > 0:
+        code = insert_spaces(code, space_interval)
+
     return code
 
 
-def get_code_set(count, code_length, mode="ascii"):
+def get_code_set(count, code_length, mode="ascii", space_interval=0):
     """
     Generate a set of unique, random codes.
 
@@ -52,13 +60,14 @@ def get_code_set(count, code_length, mode="ascii"):
     ----------
     count : int
         Number of codes to be returned
-
     code_length : int
         The length of each code
-
     mode : string
         'ascii' for letters (default), 'digits' for digits and 'combo'
         for combination of letters and digits.
+    space_interval : int
+        Spaces will be inserted into code each interval for readability if not
+        0, by default 0.
 
     Return
     ------
@@ -68,7 +77,8 @@ def get_code_set(count, code_length, mode="ascii"):
     codes = set()
 
     while len(codes) < count:
-        codes.add(get_code(code_length, mode))
+        code = get_code(code_length, mode, space_interval)
+        codes.add(code)
 
     return codes
 
@@ -93,3 +103,26 @@ def get_code_length_needed(number_of_entries):
         code_length = code_length + 1
 
     return code_length
+
+
+def insert_spaces(code, interval):
+    """Insert space after every x'th character, x = interval.
+
+    Parameters
+    ----------
+    code : string
+        String to add spaces to.
+    interval : int
+        Interval for inserting spaces.
+
+    Returns
+    -------
+    string
+        code separated with spaces.
+    """
+    # Convert to list to insert spaces between characters
+    code = list(code)
+    for i in range(interval - 1, len(code), interval):
+        code[i] += " "
+
+    return "".join(code)
diff --git a/soitool/media/authenticationboardmodule.PNG b/soitool/media/authenticationboardmodule.PNG
new file mode 100644
index 0000000000000000000000000000000000000000..0a92c03e3e403e6cb1bd41716b7feacc5d050b69
Binary files /dev/null and b/soitool/media/authenticationboardmodule.PNG differ
diff --git a/soitool/modules/module_authentication_board.py b/soitool/modules/module_authentication_board.py
new file mode 100644
index 0000000000000000000000000000000000000000..92a35dd08f7fe9f9b030def1940564da9bd4b369
--- /dev/null
+++ b/soitool/modules/module_authentication_board.py
@@ -0,0 +1,285 @@
+"""Module containing SOI-module 'Autentiseringstavle'."""
+import string
+from PySide2.QtWidgets import QTableWidget, QTableWidgetItem
+from PySide2 import QtGui
+from PySide2.QtCore import Qt
+from soitool.coder import get_code_set, get_code
+from soitool.modules.module_base import (
+    ModuleBase,
+    get_table_size,
+    resize_table,
+    HEADLINE_FONT,
+)
+
+START_NO_OF_AUTHENTICATION_CODES = 10
+CODE_LENGTH = 25
+
+# Has to be 'ascii', 'digits' or 'combo'. Codes will consist of A-Z if
+# 'ascii', 0-9 if 'digits' and A-Z+0-9 if 'combo'.
+CODE_CHARACTERS = "ascii"
+
+# Characters for first column. It's length determines maximum number of
+# codes (rows).
+ROW_IDENTIFIERS = string.ascii_uppercase
+
+# Adds space between sets of characters, 0 => no spaces
+# If code is 123456 and interval is 2, code will be 12 34 56
+SPACE_INTERVAL = 5
+
+HEADLINE_TEXT = "Autentiseringstavle"
+
+
+class Meta(type(ModuleBase), type(QTableWidget)):
+    """Used as a metaclass to enable multiple inheritance."""
+
+
+class AuthenticationBoardModule(ModuleBase, QTableWidget, metaclass=Meta):
+    """Modified QTableWidget representing a 'Autentiseringstavle'.
+
+    By default, the widget initializes with a headline, a row-count of
+    START_NO_OF_AUTHENTICATION_CODES and three columns.
+    Row x in the first column contains the character ROW_IDENTIFIERS[x].
+    Row x in the second column contains the character x.
+    Row x in the third column contains an authentication code.
+
+    If parameters are given, the widget initializes accordingly:
+    'size' is a dict: {"width": int, "height": int},
+    'data' is a 2D list where data[0] is the headline,
+    and data[x][y] represents row x, column y.
+
+    The widget does not use more room than needed, and resizes dynamically.
+    It has shortcuts for adding and removing rows.
+
+    Inherits from ModuleBase and QTableWidget.
+    ModuleBase is used as an interface, it's methods are overridden.
+    """
+
+    def __init__(self, size=None, data=None):
+        self.type = "AuthenticationBoardModule"
+        QTableWidget.__init__(self)
+        ModuleBase.__init__(self)
+
+        if CODE_CHARACTERS == "ascii":
+            self.code_characters = string.ascii_uppercase
+        elif CODE_CHARACTERS == "digits":
+            self.code_characters = string.digits
+        elif CODE_CHARACTERS == "combo":
+            self.code_characters = string.ascii_uppercase + string.digits
+        else:
+            raise ValueError(
+                "Invalid value for CONSTANT 'CODE_CHARACTERS': "
+                "'{}'".format(CODE_CHARACTERS)
+            )
+
+        # Remove headers and scrollbars
+        self.horizontalHeader().hide()
+        self.verticalHeader().hide()
+        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+
+        # If parameters are None, generate new table
+        if size is None and data is None:
+            self.generate_table()
+            self.resizeColumnsToContents()
+            self.insert_headline()
+
+            # Resize height of rows and set size of window
+            resize_table(self, resize_column=False)
+        else:
+            self.setColumnCount(len(data[1]))
+            self.setRowCount(len(data) - 1)  # - 1 to skip headline
+
+            # Set cell-items
+            for i in range(self.rowCount()):
+                for j in range(self.columnCount()):
+                    item = QTableWidgetItem(data[i + 1][j])  # +1 skip headline
+                    self.setItem(i, j, item)
+
+            self.resizeColumnsToContents()
+            resize_table(self, resize_column=False)
+            self.setFixedWidth(size["width"])
+            self.setFixedHeight(size["height"])
+
+            self.insert_headline(data[0])
+
+    def generate_table(self):
+        """Insert row identifiers and authentication codes."""
+        # Set number of rows and columns
+        self.setRowCount(START_NO_OF_AUTHENTICATION_CODES)
+        self.setColumnCount(3)
+
+        # Generate codes
+        codes = list(
+            get_code_set(
+                START_NO_OF_AUTHENTICATION_CODES,
+                CODE_LENGTH,
+                CODE_CHARACTERS,
+                SPACE_INTERVAL,
+            )
+        )
+
+        # Insert table data
+        for i in range(START_NO_OF_AUTHENTICATION_CODES):
+            # Insert non-editable row identifier in first column
+            item_first = QTableWidgetItem(ROW_IDENTIFIERS[i])
+            item_first.setFlags(item_first.flags() ^ Qt.ItemIsEditable)
+            self.setItem(i, 0, item_first)
+
+            # Insert non-editable row identifier (int) in second column
+            item_second = QTableWidgetItem(str(i))
+            item_second.setFlags(item_second.flags() ^ Qt.ItemIsEditable)
+            self.setItem(i, 1, item_second)
+
+            # Insert non-editable code in third column
+            item_third = QTableWidgetItem(codes[i])
+            item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable)
+            self.setItem(i, 2, item_third)
+
+    def insert_headline(self, text=HEADLINE_TEXT):
+        """Insert headline text.
+
+        Parameters
+        ----------
+        text : string, optional
+            The headline text, by default HEADLINE_TEXT
+        """
+        item_headline = QTableWidgetItem(text)
+        item_headline.setTextAlignment(Qt.AlignHCenter)
+        item_headline.setFont(HEADLINE_FONT)
+
+        self.insertRow(0)
+        self.setItem(0, 0, item_headline)
+        self.setSpan(0, 0, 1, self.columnCount())  # Make cell span all columns
+
+    def generate_unique_authentication_code(self):
+        """Generate authentication-code that does not already exist.
+
+        Returns
+        -------
+        string
+            Generated, unique authentication-code
+        """
+        # Get existing codes
+        existing_codes = self.get_codes()
+
+        # Randomly generate a new code until it is unique
+        unique_code = False
+        while not unique_code:
+            code = get_code(CODE_LENGTH, CODE_CHARACTERS, SPACE_INTERVAL)
+            unique_code = code not in existing_codes
+
+        return code
+
+    def keyPressEvent(self, event):
+        """Add or remove row when 'Ctrl + +' and 'Ctrl + -' are pressed."""
+        if (
+            event.modifiers() == Qt.ControlModifier
+            and event.key() == Qt.Key_Plus
+        ):
+            self.add_row()
+        elif (
+            event.modifiers() == Qt.ControlModifier
+            and event.key() == Qt.Key_Underscore
+        ):
+            self.remove_row()
+        else:
+            super(AuthenticationBoardModule, self).keyPressEvent(event)
+
+    def add_row(self):
+        """Insert row below the selected row and add data."""
+        row_index = self.currentRow()
+        # If maximum amount of rows not reached and a row is selected
+        # (+ 1 to skip row containing headline)
+        if self.rowCount() < len(ROW_IDENTIFIERS) + 1 and row_index != -1:
+            # Generate unique code and insert row
+            code = self.generate_unique_authentication_code()
+            self.insertRow(row_index + 1)
+
+            # Loop through all rows starting with the new row
+            for i in range(row_index + 1, self.rowCount()):
+                # Insert row identifier in first column
+                item_first = QTableWidgetItem(self.code_characters[i - 1])
+                item_first.setFlags(item_first.flags() ^ Qt.ItemIsEditable)
+                self.setItem(i, 0, item_first)
+
+                # Insert row identifier (int) in second column
+                item_second = QTableWidgetItem(str(i - 1))
+                item_second.setFlags(item_second.flags() ^ Qt.ItemIsEditable)
+                self.setItem(i, 1, item_second)
+
+            # Insert authentication-code in third column
+            item_third = QTableWidgetItem(code)
+            item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable)
+            self.setItem(row_index + 1, 2, item_third)
+
+            # Resize code-column in case it got wider
+            # Example: 'BGD' is wider than 'III' (depending on font)
+            self.resizeColumnToContents(2)
+            resize_table(self, resize_column=False)
+
+    def remove_row(self):
+        """Remove selected row."""
+        row_index = self.currentRow()
+        # If at least one row (+ headline-row) exists and a row other than
+        # headline-row is selected
+        if self.rowCount() > 2 and row_index != 0 and row_index != -1:
+            # Remove row
+            self.removeRow(row_index)
+            # 'Decrease' row identifiers below the removed row
+            # If first row is removed, identifier A,B,C becomes A,B (not B,C)
+            for i in range(row_index, self.rowCount()):
+                self.item(i, 0).setText(self.code_characters[i - 1])
+                self.item(i, 1).setText(str(i - 1))
+        resize_table(self, resize_column=False)
+
+    def get_codes(self):
+        """Get all authentication-codes in table.
+
+        Returns
+        -------
+        List
+            List containing authentication-codes.
+        """
+        codes = []
+
+        # Start with row 1 to skip headline-row
+        for i in range(1, self.rowCount()):
+            codes.append(self.item(i, 2).text())
+
+        return codes
+
+    def get_size(self):
+        """Return size of widget."""
+        return get_table_size(self)
+
+    def get_data(self):
+        """Return list containing all data.
+
+        Returns
+        -------
+        List
+            List[0] contains headline,
+            list[x][y] represents value of row x, column y.
+        """
+        content = []
+        item_headline = self.item(0, 0)
+        if item_headline is not None:
+            content.append(item_headline.text())
+
+        for i in range(1, self.rowCount()):
+            row = []
+            for j in range(self.columnCount()):
+                row.append(self.item(i, j).text())
+            content.append(row)
+
+        return content
+
+    @staticmethod
+    def get_user_friendly_name():
+        """Get user-friendly name of module."""
+        return "Autentiseringstavle"
+
+    @staticmethod
+    def get_icon():
+        """Get icon of module."""
+        return QtGui.QIcon("soitool/media/authenticationboardmodule.png")
diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py
index 3548eb8e1a30faf396603c00079a09ad8cf452e3..c608a17177d43a16371b675d8f5a815f82a87e27 100644
--- a/soitool/modules/module_base.py
+++ b/soitool/modules/module_base.py
@@ -2,6 +2,7 @@
 from abc import ABC
 from PySide2 import QtGui
 
+# Font for module headline
 HEADLINE_FONT = QtGui.QFont()
 HEADLINE_FONT.setFamily("Arial")
 HEADLINE_FONT.setPointSize(12)
@@ -21,14 +22,6 @@ class ModuleBase(ABC):
         """Abstract method, should be implemented by derived class."""
         raise NotImplementedError
 
-    def set_pos(self, pos):
-        """Abstract method, should be implemented by derived class."""
-        raise NotImplementedError
-
-    def render_onto_pdf(self):
-        """Abstract method, should be implemented by derived class."""
-        raise NotImplementedError
-
     def get_data(self):
         """Abstract method, should be implemented by derived class."""
         raise NotImplementedError
@@ -42,3 +35,65 @@ class ModuleBase(ABC):
     def get_icon():
         """Abstract method, should be implemented by derived class."""
         raise NotImplementedError
+
+
+def resize_table(widget, resize_row=True, resize_column=True):
+    """Calculate and set the size of a QTableWidget.
+
+    Parameters
+    ----------
+    widget : QTableWidget
+        QTablewidget-instance to calculate and set size.
+    resize_row : bool
+        Resizes rows to contents if True, by default True.
+    resize_column : bool
+        Resizes columns to contents if True, by default True.
+    """
+    if resize_row:
+        widget.resizeRowsToContents()
+    if resize_column:
+        widget.resizeColumnsToContents()
+
+    width, height = get_table_size(widget)
+
+    widget.setFixedWidth(width)
+    widget.setFixedHeight(height)
+
+
+def get_table_size(widget):
+    """Calculate and return total width and height of a QTableWidget.
+
+    Parameters
+    ----------
+    widget : QTableWidget
+        QTableWidget-instance to calculate and return size of.
+
+    Returns
+    -------
+    Tuple
+        Total (width, height)
+    """
+    # Calculate total width and height of columns and rows
+    width = 0
+    height = 0
+
+    for i in range(widget.columnCount()):
+        width += widget.columnWidth(i) + 0.5
+
+    for i in range(widget.rowCount()):
+        height += widget.rowHeight(i) + 0.5
+
+    return width, height
+
+
+def set_module_pos(widget, pos):
+    """Set position of module (widget).
+
+    Parameters
+    ----------
+    widget : QWidget
+        Widget to move.
+    pos : QPoint
+        Position (x, y).
+    """
+    widget.move(pos)
diff --git a/soitool/modules/module_freetext.py b/soitool/modules/module_freetext.py
index 4c3ab4fecc34754f61558675ee35550c90f79888..95066bceea66e73d73013f3b930fecd92ef1484d 100644
--- a/soitool/modules/module_freetext.py
+++ b/soitool/modules/module_freetext.py
@@ -162,19 +162,6 @@ class FreeTextModule(ModuleBase, QWidget, metaclass=Meta):
         size = self.sizeHint()
         return (size.width(), size.height())
 
-    def set_pos(self, pos):
-        """Set position of widget.
-
-        Parameters
-        ----------
-        pos : QPoint
-            Position (x, y).
-        """
-        self.move(pos)
-
-    def render_onto_pdf(self):
-        """Render onto pdf."""
-
     def get_data(self):
         """Return list containing module data.
 
diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py
index a4fd8d2745370221ee58c3d7c08e946686046c95..030ad405ddfc7142d84f0ac45697af09e7c685e3 100644
--- a/soitool/modules/module_table.py
+++ b/soitool/modules/module_table.py
@@ -1,8 +1,12 @@
-"""Module containing subclassed SOIModule (QTableWidget, ModuleBase)."""
+"""Module containing a general SOI-module table."""
 from PySide2.QtWidgets import QTableWidget, QTableWidgetItem
 from PySide2 import QtGui, QtCore
-from PySide2.QtGui import QIcon
-from soitool.modules.module_base import ModuleBase, HEADLINE_FONT
+from soitool.modules.module_base import (
+    ModuleBase,
+    resize_table,
+    get_table_size,
+    HEADLINE_FONT,
+)
 
 START_ROWS = 2
 START_COLUMNS = 2
@@ -15,17 +19,17 @@ class Meta(type(ModuleBase), type(QTableWidget)):
 class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
     """Modified QTableWidget.
 
-    Inherits from ModuleBase and QTableWidget.
-    ModuleBase is used as an interface, it's methods are overridden.
+    By default, the widget initializes as an empty START_ROWS * START_COLUMNS
+    table. If parameters are given, the table initializes accordingly:
+    'size' is a dict: {"width": int, "height": int},
+    'data' is a 2D list where data[x][y] represents row x, column y.
 
     The widget does not use more room than needed, and resizes dynamically.
     Columnheaders are styled with light grey background and bold text.
-    Has shortcuts for adding and removing rows and columns.
+    It has shortcuts for adding and removing rows and columns.
 
-    By default, the widget initializes as an empty START_ROWS * START_COLUMNS
-    table. If parameters are given, the table initializes accordingly:
-    'size' is a dict: {"width": int, "height": int},
-    'data' is a 2D list where content[x][y] represents row x, column y.
+    Inherits from ModuleBase and QTableWidget.
+    ModuleBase is used as an interface, it's methods are overridden.
     """
 
     def __init__(self, size=None, data=None):
@@ -45,10 +49,8 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
             self.setColumnCount(START_COLUMNS)
             self.setRowCount(START_ROWS)
 
-            # Resize width and height of columns and rows, & set size of window
-            self.resize()
-            self.setFixedWidth(START_COLUMNS * self.columnWidth(0) + 2)
-            self.setFixedHeight(START_ROWS * self.rowHeight(0) + 5)
+            # Resize width and height of rows, columns and window
+            resize_table(self, resize_row=True, resize_column=True)
 
             # Set header-items
             for i in range(self.columnCount()):
@@ -72,7 +74,9 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
             self.setFixedWidth(size["width"])
             self.setFixedHeight(size["height"])
 
-        self.cellChanged.connect(self.resize)
+        self.cellChanged.connect(
+            lambda: resize_table(self, resize_row=True, resize_column=True)
+        )
 
     def keyPressEvent(self, event):
         """Launch actions when specific combinations of keys are pressed.
@@ -127,80 +131,28 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
         """Add column to the right of selected column."""
         self.insertColumn(self.currentColumn() + 1)
         self.set_header_item(self.currentColumn() + 1, "")
-        self.resize()
+        resize_table(self, resize_row=True, resize_column=True)
 
     def remove_column(self):
         """Remove selected column if two or more columns exist."""
         if self.columnCount() > 1:
             self.removeColumn(self.currentColumn())
-            self.resize()
+            resize_table(self, resize_row=True, resize_column=True)
 
     def add_row(self):
         """Add row below selected row."""
         self.insertRow(self.currentRow() + 1)
-        self.resize()
+        resize_table(self, resize_row=True, resize_column=True)
 
     def remove_row(self):
         """Remove selected row if two or more rows exist (including header)."""
         if self.rowCount() > 2 and self.currentRow() != 0:
             self.removeRow(self.currentRow())
-            self.resize()
-
-    def resize(self):
-        """Resize widget, rows and columns.
-
-        Resize widget size to total width and height of rows and columns.
-        Resize rows and columns to contents.
-        """
-        self.resizeColumnsToContents()
-        self.resizeRowsToContents()
-
-        # Calculate total width and height of columns and rows
-        width = 0
-        height = 0
-
-        for x in range(self.columnCount()):
-            width += self.columnWidth(x) + 0.5
-
-        for y in range(self.rowCount()):
-            height += self.rowHeight(y) + 0.5
-
-        # Set total width and height
-        self.setFixedWidth(width)
-        self.setFixedHeight(height)
+            resize_table(self, resize_row=True, resize_column=True)
 
     def get_size(self):
-        """Get size of widget.
-
-        Returns
-        -------
-        Tuple
-            (width, height) (total)
-        """
-        # Calculate total width and height of columns and rows
-        width = 0
-        height = 0
-
-        for i in range(self.columnCount()):
-            width += self.columnWidth(i) + 0.5
-
-        for i in range(self.rowCount()):
-            height += self.rowHeight(i) + 0.5
-
-        return width, height
-
-    def set_pos(self, pos):
-        """Set position of widget.
-
-        Parameters
-        ----------
-        pos : QPoint
-            Position (x, y).
-        """
-        self.move(pos)
-
-    def render_onto_pdf(self):
-        """Render onto pdf."""
+        """Return size of widget."""
+        return get_table_size(self)
 
     def get_data(self):
         """Return list containing module data.
@@ -231,4 +183,4 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta):
     @staticmethod
     def get_icon():
         """Get icon of module."""
-        return QIcon("soitool/media/tablemodule.png")
+        return QtGui.QIcon("soitool/media/tablemodule.png")
diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py
index 63ec67bbfa6728d653edf7e6a6afee853c0aa007..acc3746ca017a7d8869694a71d58ee7788843338 100644
--- a/soitool/new_module_dialog.py
+++ b/soitool/new_module_dialog.py
@@ -12,47 +12,20 @@ from PySide2.QtWidgets import (
     QCheckBox,
 )
 from PySide2.QtCore import QSize, Qt
-from PySide2.QtGui import QIcon
 from soitool.modules.module_table import TableModule
-from soitool.modules.module_base import ModuleBase
+from soitool.modules.module_authentication_board import (
+    AuthenticationBoardModule,
+)
 from soitool.modules.module_freetext import FreeTextModule
 
 
-class ModulePlaceholder(ModuleBase):
-    """Dummy module used only to fill dialog with content while developing."""
-
-    def set_pos(self, pos):
-        """Not used."""
-        raise NotImplementedError
-
-    def get_size(self):
-        """Not used."""
-        raise NotImplementedError
-
-    def render_onto_pdf(self):
-        """Not used."""
-        raise NotImplementedError
-
-    @staticmethod
-    def get_user_friendly_name():
-        """Get placeholder name."""
-        return "Modul"
-
-    @staticmethod
-    def get_icon():
-        """Get standard placeholder icon."""
-        return QIcon("soitool/media/placeholder.png")
-
-
 # Constant holding all modules the user can choose from. This is intended as a
 # point of extensibility. Further modules that are developed can simply be
 # placed here, and the rest of the program will respect them.
 MODULE_CHOICES = [
     TableModule,
+    AuthenticationBoardModule,
     FreeTextModule,
-    ModulePlaceholder,
-    ModulePlaceholder,
-    ModulePlaceholder,
 ]
 
 
diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py
index c585a5d0f748e59dd7d9a7a82a90b5de3bd0ad59..d7856c1e2f6bd48e840f3486f093918864075abd 100644
--- a/soitool/serialize_export_import_soi.py
+++ b/soitool/serialize_export_import_soi.py
@@ -5,6 +5,9 @@ from schema import Schema, And, Or
 from soitool.soi import SOI
 from soitool.compressor import compress, decompress
 from soitool.modules.module_table import TableModule
+from soitool.modules.module_authentication_board import (
+    AuthenticationBoardModule,
+)
 from soitool.modules.module_freetext import FreeTextModule
 
 # Valid schema for serialized SOI
@@ -219,6 +222,15 @@ def import_soi(file_path):
             modules.append(
                 {"widget": TableModule(size, data), "meta": module["meta"]}
             )
+        elif module_type == "AuthenticationBoardModule":
+            size = module["size"]
+            data = module["data"]
+            modules.append(
+                {
+                    "widget": AuthenticationBoardModule(size, data),
+                    "meta": module["meta"],
+                }
+            )
         elif module_type == "FreeTextModule":
             size = module["size"]
             data = module["data"]
diff --git a/soitool/soi.py b/soitool/soi.py
index ea29768893f781809f986a7221f6082c6b4a768b..8ac55dc93b54393b1c28860e9d2244e2af8a3308 100644
--- a/soitool/soi.py
+++ b/soitool/soi.py
@@ -11,8 +11,9 @@ from rectpack import (
     skyline,
     guillotine,
 )
+from soitool.modules.module_base import set_module_pos
 
-# functions to sort modules by different criteria
+# Functions to sort modules by different criteria
 
 
 def modules_sort_by_none(modules):
@@ -381,7 +382,7 @@ class SOI:
             + scene_skip_distance_page_height
         )
 
-        module["widget"].set_pos(QPoint(new_x, new_y))
+        set_module_pos(module["widget"], QPoint(new_x, new_y))
 
     def get_module_with_name(self, name):
         """Return module with given name.
diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py
index 520a3bbaed8d9261d9889fe5bc93f226a53bf5fb..5355999e61f2dad4d18d28c4c3bf9004990adf07 100644
--- a/soitool/soi_workspace_widget.py
+++ b/soitool/soi_workspace_widget.py
@@ -16,7 +16,7 @@ from soitool.soi import SOI, ModuleType, ModuleNameTaken
 from soitool.module_list import ModuleList
 from soitool.inline_editable_soi_view import InlineEditableSOIView
 from soitool.setup_settings import Setup
-from soitool.new_module_dialog import NewModuleDialog, ModulePlaceholder
+from soitool.new_module_dialog import NewModuleDialog
 from soitool.dialog_wrappers import exec_warning_dialog
 
 
@@ -102,42 +102,27 @@ class SOIWorkspaceWidget(QWidget):
             module_widget_implementation = chosen_module.widget_implementation
             is_attachment = new_module_dialog.checkbox_attachment.isChecked()
 
-            if module_widget_implementation is ModulePlaceholder:
+            # No module name means the user expects one to be generated
+            # Autogenerated name is not meant to be pretty, it's just meant
+            # to be unique
+            if not module_name:
+                module_name = "{} {}".format(
+                    module_choice,
+                    str(len(self.soi.modules) + len(self.soi.attachments) + 1),
+                )
+
+            try:
+                self.soi.add_module(
+                    module_name, module_widget_implementation(), is_attachment,
+                )
+            except ModuleNameTaken:
                 exec_warning_dialog(
                     text="Modulen ble ikke lagt til.",
-                    informative_text="Den valgte modulen er ikke "
-                    "implementert. Modulen er trolig bare valgbar for å fylle "
-                    "ut valgene til flere moduler er implementert.",
+                    informative_text="Navnet du valgte er allerede i "
+                    "bruk. Modulnavn må være unike. Velg et unikt "
+                    "modulnavn, eller la programmet lage et navn "
+                    "automatisk.",
                 )
-            else:
-                # no module name means the user expects one to be generated
-                # autogenerated name is not meant to be pretty, it's just meant
-                # to be unique
-                if not module_name:
-                    module_name = "{} {}".format(
-                        module_choice,
-                        str(
-                            len(self.soi.modules)
-                            + len(self.soi.attachments)
-                            + 1
-                        ),
-                    )
-
-                try:
-                    self.soi.add_module(
-                        module_name,
-                        module_widget_implementation(),
-                        is_attachment,
-                    )
-                except ModuleNameTaken:
-                    exec_warning_dialog(
-                        text="Modulen ble ikke lagt til.",
-                        informative_text="Navnet du valgte er allerede i "
-                        "bruk. Modulnavn må være unike. Velg et unikt "
-                        "modulnavn, eller la programmet lage et navn "
-                        "automatisk.",
-                    )
-
         elif dialogcode == QDialog.DialogCode.Rejected:
             pass
         else:
diff --git a/test/test_module_authentication_board.py b/test/test_module_authentication_board.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b3262e48055e0290b8676076e562da3b73913c5
--- /dev/null
+++ b/test/test_module_authentication_board.py
@@ -0,0 +1,182 @@
+"""Test AuthenticationBoardModule."""
+import unittest
+from PySide2 import QtGui
+from PySide2.QtWidgets import QApplication
+from PySide2.QtCore import Qt
+from PySide2.QtTest import QTest
+from soitool.modules.module_authentication_board import (
+    AuthenticationBoardModule,
+    START_NO_OF_AUTHENTICATION_CODES,
+    HEADLINE_TEXT,
+    ROW_IDENTIFIERS,
+    CODE_LENGTH,
+)
+from soitool.soi import SOI
+
+if isinstance(QtGui.qApp, type(None)):
+    app = QApplication([])
+else:
+    app = QtGui.qApp
+
+
+class TestDefaultAuthenticationBoardModule(unittest.TestCase):
+    """TestCase for AuthenticationBoardModule."""
+
+    def setUp(self):
+        """Create new AuthenticationBoardModule."""
+        self.module = AuthenticationBoardModule()
+
+    def test_default_module(self):
+        """Test that module is initialized properly."""
+        # Assert correct headline
+        self.assertEqual(self.module.item(0, 0).text(), HEADLINE_TEXT)
+
+        # Assert correct number of rows including header
+        self.assertEqual(
+            self.module.rowCount(), START_NO_OF_AUTHENTICATION_CODES + 1
+        )
+        # Assert correct number of columns
+        self.assertEqual(self.module.columnCount(), 3)
+
+        # Assert cell content in first column is correct
+        self.assertEqual(self.module.item(1, 0).text(), ROW_IDENTIFIERS[0])
+        self.assertEqual(self.module.item(2, 0).text(), ROW_IDENTIFIERS[1])
+
+        # Assert cell content in second column is correct
+        self.assertEqual(self.module.item(1, 1).text(), "0")
+        self.assertEqual(self.module.item(2, 1).text(), "1")
+
+        # Assert cell content in third column is correct
+        code = self.module.item(1, 2).text()
+        self.assertTrue(len(code) >= CODE_LENGTH)
+        # Assert all code characters are taken from list code_characters
+        for _, character in enumerate(code):
+            if character != " ":
+                self.assertTrue(character in self.module.code_characters)
+        # Assert all codes are unique
+        code_list = self.module.get_codes()
+        code_set = set(code_list)
+        self.assertEqual(len(code_list), len(code_set))
+
+    def test_generate_unique_authentication_code(self):
+        """Test function generate_unique_authentication_module."""
+        code_list = self.module.get_codes()
+        generated_code = self.module.generate_unique_authentication_code()
+
+        # Assert code length is equal to an existing code
+        self.assertEqual(len(generated_code), len(code_list[0]))
+        # Assert generated code is not equal to any existing codes
+        self.assertFalse(generated_code in code_list)
+
+    def test_get_codes(self):
+        """Test function get_codes."""
+        # Get codes
+        code_list = self.module.get_codes()
+
+        # Assert codes are correct
+        for i, code in enumerate(code_list):
+            self.assertEqual(code, self.module.item(i + 1, 2).text())
+
+    def test_gui_add_row(self):
+        """Test adding rows with shortcuts."""
+        # Widget must be shown for shortcuts to work
+        self.module.show()
+        QTest.qWaitForWindowExposed(self.module)
+
+        # From experience it can happen that the widget is not fully
+        # initialized before the QTest.keyClicks below. By calling
+        # processEvents here we give Qt the opportunity to catch up with us
+        app.processEvents()
+
+        old_row_count = self.module.rowCount()
+
+        # Use shortcut 'Ctrl + +'
+        QTest.keyClicks(self.module, "+", Qt.ControlModifier)
+
+        # Assert a new row is added
+        new_row_count = self.module.rowCount()
+        self.assertEqual(old_row_count + 1, new_row_count)
+
+        # Assert new row has correct content
+        row_index = new_row_count - 1
+        self.assertEqual(
+            self.module.item(row_index, 0).text(),
+            ROW_IDENTIFIERS[row_index - 1],
+        )
+        self.assertEqual(
+            self.module.item(row_index, 1).text(), str(row_index - 1)
+        )
+        new_code = self.module.item(row_index, 2).text()
+        existing_code = self.module.item(1, 2).text()
+        self.assertEqual(len(new_code), len(existing_code))
+
+    def test_gui_remove_row(self):
+        """Test removing rows with shortcuts."""
+        # Widget must be shown for shortcuts to work
+        self.module.show()
+        QTest.qWaitForWindowExposed(self.module)
+
+        # From experience it can happen that the widget is not fully
+        # initialized before the QTest.keyClicks below. By calling
+        # processEvents here we give Qt the opportunity to catch up with us
+        app.processEvents()
+
+        old_row_count = self.module.rowCount()
+
+        # First row is selected on startup, it contains headline
+        # and user should not be able to delete it with shortcut
+        QTest.keyClicks(self.module, "_", Qt.ControlModifier)
+        # Assert row was not removed
+        new_row_count = self.module.rowCount()
+        self.assertEqual(old_row_count, new_row_count)
+
+        # Move to first row, then use shortcut to delete it
+        QTest.keyClick(self.module, Qt.Key_Down)
+        QTest.keyClicks(self.module, "_", Qt.ControlModifier)
+        # Assert row was removed and replaced by the row below
+        value_to_delete = self.module.item(1, 1).text()
+        new_row_count = self.module.rowCount()
+        self.assertEqual(old_row_count - 1, new_row_count)
+        self.assertEqual(self.module.item(1, 1).text(), value_to_delete)
+
+        # Remove rows until only headline-row and a single code-row exist
+        for _ in range(1, self.module.rowCount() - 1):
+            QTest.keyClick(self.module, Qt.Key_Down)
+            QTest.keyClicks(self.module, "_", Qt.ControlModifier)
+        self.assertTrue(self.module.rowCount() == 2)
+        # Try to remove final code-row, should not work
+        QTest.keyClick(self.module, Qt.Key_Down)
+        QTest.keyClicks(self.module, "_", Qt.ControlModifier)
+        # Assert row was not removed
+        self.assertTrue(self.module.rowCount() == 2)
+
+    def test_add_to_soi_smoke_test(self):
+        """Test that module can be added to SOI."""
+        soi = SOI()
+        module_name = "Test name"
+        soi.add_module(module_name, self.module)
+        self.assertTrue(soi.module_name_taken(module_name))
+
+
+class TestAuthenticationBoardModuleFromData(unittest.TestCase):
+    """TestCase for initializing AuthenticationBoardModule from data."""
+
+    def test_create_from_data(self):
+        """Test creating AuthenticationBoardModule from data."""
+        test_data = [
+            "Headline text",
+            ["A", "0", "TEST CODE ONE"],
+            ["B", "1", "TEST CODE TWO"],
+        ]
+        test_size = {"width": 100, "height": 100}
+
+        module = AuthenticationBoardModule(size=test_size, data=test_data)
+
+        # Assert module contains expected data
+        self.assertEqual(module.item(0, 0).text(), "Headline text")
+        self.assertEqual(module.item(1, 0).text(), "A")
+        self.assertEqual(module.item(1, 1).text(), "0")
+        self.assertEqual(module.item(1, 2).text(), "TEST CODE ONE")
+        self.assertEqual(module.item(2, 0).text(), "B")
+        self.assertEqual(module.item(2, 1).text(), "1")
+        self.assertEqual(module.item(2, 2).text(), "TEST CODE TWO")