diff --git a/config.py b/config.py
index f990a5daa952955da33be8742615bda5ea766f75..ee6ed8d6518887c6289551d29d6f2adad3b15e8e 100644
--- a/config.py
+++ b/config.py
@@ -12,6 +12,7 @@ KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/ke
 
 MISP_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/misp")
 THEHIVE_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/thehive_secret_key")
+CORTEX_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/cortex_secret_key")
 
 # Credentials of the special user for account management
 # Cert and key should be in .pem format, unencrypted
@@ -35,3 +36,6 @@ MISP_API_KEY = None
 
 THEHIVE_API_KEY = None
 THEHIVE_ORG_NAME = None # set to "domain" from variables file
+
+CORTEX_API_KEY = None
+CORTEX_ORG_NAME = None # set to "domain" from variables file
diff --git a/cortex.py b/cortex.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbbaf15a7810a69f9b219ab06c76fd5cb635c876
--- /dev/null
+++ b/cortex.py
@@ -0,0 +1,142 @@
+"""Functions to manage user accounts in Cortex"""
+
+from typing import List, Dict, Optional
+import requests
+from datetime import datetime
+
+import config
+
+# Base URL to Cortex API endpoints
+CORTEX_API_BASE_URL = "https://{soctools_proxy}:9001/api"
+
+# Cortex documentation: https://github.com/Cortex-Project/CortexDocs/blob/master/api/api-guide.md
+
+class CortexError(Exception):
+    pass
+
+class CortexUserNotFoundError(CortexError):
+    pass
+
+class CortexUserExistsError(CortexError):
+    pass
+
+class CortexUnexpectedReplyError(CortexError):
+    pass
+
+
+# =========================
+# Public interface
+
+def cortex_get_users() -> List[Dict]:
+    """
+    List users defined in Cortex in the configured organization
+
+    :return List of dicts with keys 'login', 'name', 'roles' (list), 'status', 'created' (datetime)
+    :raise CortexUnexpectedReplyError
+    """
+    # all users in given org
+    url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/organization/{config.THEHIVE_ORG_NAME}/user"
+    # all users
+    #url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user"
+
+    resp = _send_request("get", url)
+    if not resp.ok:
+        raise CortexUnexpectedReplyError(f"Can't get list of users from Cortex: Unexpected reply {resp.status_code}")
+    users = []
+    try:
+        for user_entry in resp.json():
+            # ref: https://github.com/TheHive-Project/CortexDocs/blob/master/api/api-guide.md#user-model
+            users.append({
+                "login": user_entry["id"],
+                "name": user_entry["name"],
+                "roles": user_entry["roles"],
+                "org": user_entry["organization"],
+                "status": user_entry["status"],
+                "created": datetime.utcfromtimestamp(user_entry["createdAt"]/1000), # time of account creation (timestamp in ms -> datetime)
+            })
+    except (ValueError, TypeError, KeyError) as e:
+        print(f"Can't get list of users from Cortex: Unexpected content received: {type(e).__name__}: {e})")
+        raise CortexUnexpectedReplyError(f"Can't get list of users from Cortex: Unexpected content received")
+
+    return users
+
+
+def cortex_add_user(user: 'UserAccount') -> None:
+    """Add a new user to Cortex
+
+    :raise CortexUnexpectedReplyError
+    """
+    url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/user"
+    data = {
+        "login": user.email,
+        "name": f"{user.firstname} {user.lastname}".strip() or user.username,
+        "organization": config.CORTEX_ORG_NAME,
+        "roles": ["read", "analyze"], #, "orgadmin"],
+    }
+    resp = _send_request("post", url, data)
+    #print(resp.json())
+    if not resp.ok:
+        try:
+            if "already exists" in resp.json()["message"]:
+                raise CortexUserExistsError()
+        except Exception:
+            pass
+        print(f"Can't add user to Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}")
+        raise CortexUnexpectedReplyError(f"Can't add user to Cortex: Unexpected reply {resp.status_code}")
+    return None
+
+
+def cortex_edit_user(login: str, user: 'UserAccount') -> None:
+    """Edit existing user in Cortex (only name can be changed)
+
+    :raise CortexUserNotFoundError,CortexUnexpectedReplyError
+    """
+    url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}"
+    data = {
+        "name": f"{user.firstname} {user.lastname}".strip() or user.username,
+        #"organisation": config.CORTEX_ORG_NAME,
+        #"roles": ["read", "analyze"], # TODO allow to set different roles?
+    }
+    print(url, data)
+    resp = _send_request("patch", url, data)
+    print(resp.status_code, resp.text)
+    if not resp.ok:
+        if resp.status_code == 404:
+            raise CortexUserNotFoundError()
+        print(f"Can't edit user in Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}")
+        raise CortexUnexpectedReplyError(f"Can't edit user in Cortex: Unexpected reply {resp.status_code}")
+    return None
+
+
+def cortex_delete_user(login: str) -> None:
+    """Lock existing user from Cortex
+
+    Cortex doesn't allow to delete user accounts, they can only be marked as Locked.
+
+    :raise CortexUserNotFoundError,CortexUnexpectedReplyError
+    """
+    url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}"
+    data = {
+        "status": "Locked"
+    }
+    resp = _send_request("patch", url, data)
+    if not resp.ok:
+        if resp.status_code == 404:
+            raise CortexUserNotFoundError()
+        print(f"Can't delete user from Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}")
+        raise CortexUnexpectedReplyError(f"Can't delete user from Cortex: Unexpected reply {resp.status_code}")
+    return None
+
+
+# =========================
+# Auxiliary functions
+
+def _send_request(method:str, url:str, data:Optional[dict]=None):
+    return getattr(requests, method)(
+        url,
+        headers={
+            "Authorization": "Bearer " + config.CORTEX_API_KEY,
+        },
+        verify=config.CA_CERT_FILE,
+        json=data
+    )
diff --git a/main.py b/main.py
index 2666941bc02c1d59e7ad25db05c378062ce27241..dea769b96148cabdcccb2667da8c65a815c6af52 100644
--- a/main.py
+++ b/main.py
@@ -1,8 +1,6 @@
 #!/usr/bin/env python3
 import sys
 from datetime import datetime, timezone
-import os.path
-import re
 from typing import List, Dict, Optional, Union, Tuple
 
 from flask import Flask, render_template, request, make_response, redirect, flash, send_file
@@ -19,6 +17,7 @@ import certificates
 from nifi import *
 from misp import *
 from thehive import *
+from cortex import *
 
 app = Flask(__name__)
 app.secret_key = "ASDF1234 - CHANGE ME!"
@@ -43,13 +42,17 @@ def load_config():
     config.MISP_API_KEY = open(config.MISP_API_KEY_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long
     # Load API key for The Hive
     config.THEHIVE_API_KEY = open(config.THEHIVE_API_KEY_FILE, "r").read(100).strip()  # read max 100 B, the key should never be so long
-    # Load organization name (for The Hive)
-    config.THEHIVE_ORG_NAME = variables["domain"]
+    # Load API key for Cortex
+    config.CORTEX_API_KEY = open(config.CORTEX_API_KEY_FILE, "r").read(100).strip()  # read max 100 B, the key should never be so long
+    # Load organization name (for The Hive and Cortex)
+    config.THEHIVE_ORG_NAME = variables["org_name"]
+    config.CORTEX_ORG_NAME = variables["org_name"]
 
     print(f"Config loaded:\nSOCTOOLSPROXY={config.SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={config.KEYCLOAK_BASE_URL}\n"
           f"KEYCLOAK_ADMIN_PASSWORD={config.KEYCLOAK_ADMIN_PASSWORD[:3]}...{config.KEYCLOAK_ADMIN_PASSWORD[-4:]}\n"
           f"MISP_API_KEY={config.MISP_API_KEY[:3]}...{config.MISP_API_KEY[-4:]}\n"
           f"THEHIVE_API_KEY={config.THEHIVE_API_KEY[:3]}...{config.THEHIVE_API_KEY[-4:]}\n"
+          f"CORTEX_API_KEY={config.CORTEX_API_KEY[:3]}...{config.CORTEX_API_KEY[-4:]}\n"
           f"THEHIVE_ORG_NAME={config.THEHIVE_ORG_NAME}\n")
 
 
@@ -318,6 +321,16 @@ def main():
     # List of usernames only (for easier cross-check with Keycloak users)
     thehive_usernames = set(u["login"] for u in thehive_users)
 
+    # ===================
+    # Load Cortex users
+    try:
+        cortex_users = cortex_get_users()
+    except CortexError as e:
+        flash(f"ERROR: {e}", "error")
+        cortex_users = []
+    # List of usernames only (for easier cross-check with Keycloak users)
+    cortex_usernames = set(u["login"] for u in cortex_users)
+
     return render_template("main.html", **locals())
 
 
@@ -376,10 +389,19 @@ def add_user():
             thehive_add_user(user)
             flash(f'User "{user.email}" successfully created in The Hive.', "success")
         except TheHiveUserExistsError:
-            flash(f'User with email "{user.email}" already exists in TheHive, nothing has changed.', "warning")
+            flash(f'User with email "{user.email}" already exists in The Hive, nothing has changed.', "warning")
         except Exception as e:
             flash(f'Error when creating user in The Hive: {e}', "error")
 
+        # Cortex
+        try:
+            cortex_add_user(user)
+            flash(f'User "{user.username}" successfully created in Cortex.', "success")
+        except CortexUserExistsError:
+            flash(f'User "{user.username}" already exists in Cortex, nothing has changed.', "warning")
+        except Exception as e:
+            flash(f'Error when creating user in Cortex: {e}', "error")
+
         # Send email to the user
         if form_user.send_email.data:
             ok, err = _send_token(user.username, user.email) # TODO
@@ -441,18 +463,33 @@ def edit_user(username: str):
 
             # The Hive
             # IMPORTANT: Email is used as user identifier, it can't be changed - we have to delete and re-create the account.
-            if user.email != new_user.email:
-                thehive_delete_user(user.email)
-                thehive_add_user(new_user)
-                flash(f'User {user.email} deleted and new user "{new_user.username}" created in The Hive.', "success")
-            else:
-                try:
+            try:
+                if user.email != new_user.email:
+                    thehive_delete_user(user.email)
+                    thehive_add_user(new_user)
+                    flash(f'User "{user.email}" deleted and new user "{new_user.email}" created in The Hive.', "success")
+                else:
                     thehive_edit_user(user.email, new_user)
-                    flash(f'User "{new_user.username}" successfully updated in The Hive.', "success")
-                except TheHiveUserNotFoundError:
-                    flash(f'Error when updating user in The Hive: User with email "{user.email}" not found', "error")
-                except Exception as e:
-                    flash(f'Error when updating user in The Hive: {e}', "error")
+                    flash(f'User "{new_user.email}" successfully updated in The Hive.', "success")
+            except TheHiveUserNotFoundError:
+                flash(f'Error when updating user in The Hive: User with email "{user.email}" not found', "error")
+            except Exception as e:
+                flash(f'Error when updating user in The Hive: {e}', "error")
+
+            # Cortex
+            # IMPORTANT: Email is used as user identifier, it can't be changed - we have to delete (lock) and re-create the account.
+            try:
+                if user.email != new_user.email:
+                    cortex_delete_user(user.email)
+                    cortex_add_user(new_user)
+                    flash(f'User "{user.email}" marked as locked and new user "{new_user.email}" created in Cortex.', "success")
+                else:
+                    cortex_edit_user(user.email, new_user)
+                    flash(f'User "{new_user.email}" successfully updated in Cortex.', "success")
+            except CortexUserNotFoundError:
+                flash(f'Error when updating user in Cortex: User with email "{user.email}" not found', "error")
+            except Exception as e:
+                flash(f'Error when updating user in Cortex: {e}', "error")
 
             return redirect("/") # Success - go back to main page
 
@@ -508,6 +545,15 @@ def delete_user(username: str):
     except TheHiveError as e:
         flash(f'Error when deleting user from The Hive: {e}', "error")
 
+    # Cortex
+    try:
+        cortex_delete_user(user_spec.email)
+        flash(f'User "{user_spec.email}" marked as "locked" in Cortex.', "success")
+    except CortexUserNotFoundError:
+        flash(f'Error when trying to mark user as "locked" in Cortex: User with email "{user_spec.email}" not found', "error")
+    except TheHiveError as e:
+        flash(f'Error when trying to mark user as "locked" in Cortex: {e}', "error")
+
     return redirect("/")
 
 
diff --git a/static/style.css b/static/style.css
index fc7ab0e6a56153250dde8a2fb43d326fb098b873..e42fb85ddece64aa8d829f0c5331a6dd09e44a56 100644
--- a/static/style.css
+++ b/static/style.css
@@ -47,3 +47,7 @@ input[readonly] {
 .internal-user {
   color: #ccc;
 }
+.locked-user {
+  color: #aaa;
+  text-decoration: line-through;
+}
diff --git a/templates/add_edit_user.html b/templates/add_edit_user.html
index d95c09aa1e9d4e34a18ec4a23f69675df9e95901..206270145f3c642a640acc90bc913dbbbe51b7e6 100644
--- a/templates/add_edit_user.html
+++ b/templates/add_edit_user.html
@@ -22,7 +22,7 @@
   {{ form_user.username.label }} {{ form_user.username(size=20, readonly=True if user else False) }}<br>
   {{ form_user.firstname.label }} {{ form_user.firstname(size=20) }}<br>
   {{ form_user.lastname.label }} {{ form_user.lastname(size=20) }}<br>
-  {{ form_user.email.label }} {{ form_user.email(size=20) }}<br>
+  {{ form_user.email.label }} {{ form_user.email(size=20) }}{% if user %} Notice: Email is used as primary identifier and can't be changed in some services (TheHive, Cortex). If it's changed, the old user account will be deleted and a new one created in those services.{% endif %}<br>
   {% if user %}
   <input type="submit" value="Update user">
   {% else %}
diff --git a/templates/main.html b/templates/main.html
index 50a828d8e9ce17b4ed54b5474072147dc15fed04..32acabd09d51f8ecb57402f6c2706b2032e4c875 100644
--- a/templates/main.html
+++ b/templates/main.html
@@ -14,8 +14,8 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 </script>
 
 <table>
-<tr><th>Username</th><th>First name</th><th>Last name</th><th>Email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th>MISP</th><th>The Hive</th><th>Actions</th>
-{% for user in users %}
+<tr><th>Username</th><th>First name</th><th>Last name</th><th>Email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th>MISP</th><th>The Hive</th><th>Cortex</th><th>Actions</th>
+{% for user in users|sort(attribute="username") %}
 <tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.username }}</td>
 <td>{{ user.firstname }}</td>
@@ -26,7 +26,8 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 <td>{{ user.ts_created|ts_to_str }}</td>
 <td>{{ icon('check' if user.username in nifi_usernames else 'close') }}</td>
 <td>{{ icon('check' if user.email in misp_emails else 'close') }}</td>
-<td>{{ icon('check' if user.login in thehive_usernames else 'close') }}</td>
+<td>{{ icon('check' if user.email in thehive_usernames else 'close') }}</td>
+<td>{{ icon('check' if user.email in cortex_usernames else 'close') }}</td>
 <td>
 {% if not user.internal -%}
 <a href="{{ url_for('edit_user', username=user.username) }}" title="Edit user">{{ icon('pencil') }}</a>
@@ -47,7 +48,7 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 <table>
 <tr><th>Username</th><th>Group</th><th>ID</th>
 
-{% for user in nifi_users %}
+{% for user in nifi_users|sort(attribute="name") %}
 <tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.name }}</td>
 <td>{{ user.groups|join(',') }}</td>
@@ -61,7 +62,7 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 <table>
 <tr><th>Email (username)</th><th>ID</th><th>Organization</th><th>Role</th><th>Created</th><th>Last login</th>
 
-{% for user in misp_users %}
+{% for user in misp_users|sort(attribute="email") %}
 <tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.email }}</td>
 <td>{{ user.id }}</td>
@@ -76,9 +77,9 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 
 <h3>The Hive</h3>
 <table>
-<tr><th>Login</th><th>Name</th><th>Organization</th><th>Role (profile)</th><th>ID</th><th>Created</th><th></th>
+<tr><th>Login</th><th>Name</th><th>Organization</th><th>Role (profile)</th><th>ID</th><th>Created</th>
 
-{% for user in thehive_users %}
+{% for user in thehive_users|sort(attribute="login") %}
 <tr{% if user.internal %} class="internal-user"{% endif %}>
 <td>{{ user.login }}</td>
 <td>{{ user.name }}</td>
@@ -90,4 +91,20 @@ document.getElementById('show-internal').addEventListener('change', function(eve
 {% endfor %}
 </table>
 
+<h3>Cortex</h3>
+<table>
+<tr><th>Login</th><th>Name</th><th>Organization</th><th>Roles</th><th>Status</th><th>Created</th>
+
+{% for user in cortex_users|sort(attribute="login")|sort(attribute="status",reverse=True) %}
+<tr{% if user.status == "Locked"%} class="locked-user"{% elif user.internal %} class="internal-user"{% endif %}>
+<td>{{ user.login }}</td>
+<td>{{ user.name }}</td>
+<td>{{ user.org }}</td>
+<td>{{ user.roles|join(', ') }}</td>
+<td>{{ user.status }}</td>
+<td>{{ user.created|ts_to_str }}</td>
+</tr>
+{% endfor %}
+</table>
+
 {% endblock %}
\ No newline at end of file
diff --git a/thehive.py b/thehive.py
index f7fec61e72db581e38409d2b0e6f26f1778c6373..2083b029abade974713df3143bc17a1e8d0c7f7b 100644
--- a/thehive.py
+++ b/thehive.py
@@ -3,12 +3,8 @@
 from typing import List, Dict, Optional
 import requests
 from datetime import datetime
-import re
-from operator import itemgetter
-import urllib.parse
 
 import config
-from main import UserAccount
 
 # Base URL to The Hive API endpoints
 THEHIVE_API_BASE_URL = "https://{soctools_proxy}:9000/api/v1"
@@ -72,7 +68,7 @@ def thehive_get_users() -> List[Dict]:
     return users
 
 
-def thehive_add_user(user: UserAccount) -> None:
+def thehive_add_user(user: 'UserAccount') -> None:
     """Add a new user to TheHive
 
     :raise TheHiveUnexpectedReplyError
@@ -80,7 +76,7 @@ def thehive_add_user(user: UserAccount) -> None:
     url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/user"
     data = {
         "login": user.email,
-        "name": f"{user.firstname} {user.lastname}" if (user.firstname or user.lastname) else user.username,
+        "name": f"{user.firstname} {user.lastname}".strip() or user.username,
         "organisation": config.THEHIVE_ORG_NAME,
         "profile": "analyst", # TODO allow to set different roles?
         #"email": user.email,
@@ -96,14 +92,14 @@ def thehive_add_user(user: UserAccount) -> None:
     return None
 
 
-def thehive_edit_user(login: str, user: UserAccount) -> None:
+def thehive_edit_user(login: str, user: 'UserAccount') -> None:
     """Edit existing user in The Hive (only name can be changed)
 
     :raise TheHiveUnexpectedReplyError
     """
     url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}"
     data = {
-        "name": f"{user.firstname} {user.lastname}".strip() if (user.firstname or user.lastname) else user.username,
+        "name": f"{user.firstname} {user.lastname}".strip() or user.username,
         #"organisation": config.THEHIVE_ORG_NAME,
         #"profile": "analyst",  # TODO allow to set different roles?
     }