diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7b934870170c205e77a6d7f3bb3aaa7d31b5d6e2..f4224315413ab7810e6cf9310a8e06717210c1a7 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -44,7 +44,7 @@
       "cwd": "${workspaceFolder}",
       "program": "${workspaceFolder}/.pyenv/bin/pytest",
       "args": [
-        "-sv", "tests/test_api.py::TestUploads::test_post[tests/data/proc/empty.zip]"
+        "-sv", "tests/test_api.py::TestAuth::test_put_user"
       ]
     },
     {
diff --git a/nomad/api/auth.py b/nomad/api/auth.py
index ddb68b2204519a6f132f97bd19e310af72e26a3c..f6db27ae3e644acb00523337b33798b05141817c 100644
--- a/nomad/api/auth.py
+++ b/nomad/api/auth.py
@@ -135,6 +135,7 @@ user_model = api.model('User', {
     'last_name': fields.String(description='The user\'s last name'),
     'email': fields.String(description='Guess what, the user\'s email'),
     'affiliation': fields.String(description='The user\'s affiliation'),
+    'password': fields.String(description='The bcrypt 2y-indented password for initial and changed password'),
     'token': fields.String(
         description='The access token that authenticates the user with the API. '
         'User the HTTP header "X-Token" to provide it in API requests.')
@@ -161,6 +162,54 @@ class UserResource(Resource):
                 401,
                 message='User not logged in, provide credentials via Basic HTTP authentication.')
 
+    @api.doc('create_user')
+    @api.expect(user_model)
+    @api.marshal_with(user_model, skip_none=True, code=200, description='User created')
+    @login_really_required
+    def put(self):
+        """
+        Creates a new user account. Currently only the admin user is allows. The
+        NOMAD-CoE repository GUI should be used to create user accounts for now.
+        Passwords have to be encrypted by the client with bcrypt and 2y indent.
+        """
+        if not g.user.is_admin:
+            abort(401, message='Only the admin user can perform create user.')
+
+        data = request.get_json()
+        if data is None:
+            data = {}
+
+        for required_key in ['last_name', 'first_name', 'password', 'email']:
+            if required_key not in data:
+                abort(400, message='The %s is missing' % required_key)
+
+        user = coe_repo.User.create_user(
+            email=data['email'], password=data.get('password', None), crypted=True,
+            first_name=data['first_name'], last_name=data['last_name'],
+            affiliation=data.get('affiliation', None))
+
+        return user, 200
+
+    @api.doc('update_user')
+    @api.expect(user_model)
+    @api.marshal_with(user_model, skip_none=True, code=200, description='User updated')
+    @login_really_required
+    def post(self):
+        """
+        Allows to edit the authenticated user and change his password. Password
+        have to be encrypted by the client with bcrypt and 2y indent.
+        """
+        data = request.get_json()
+        if data is None:
+            data = {}
+
+        if 'email' in data:
+            abort(400, message='Cannot change the users email.')
+
+        g.user.update(crypted=True, **data)
+
+        return g.user, 200
+
 
 token_model = api.model('Token', {
     'user': fields.Nested(user_model),
diff --git a/nomad/coe_repo/user.py b/nomad/coe_repo/user.py
index f3c8244fc458aff5a56a6e9825fe9fffb71b8d48..18309eff7590d51eb92ad7a264e2ded116036b2a 100644
--- a/nomad/coe_repo/user.py
+++ b/nomad/coe_repo/user.py
@@ -13,9 +13,12 @@
 # limitations under the License.
 
 from passlib.hash import bcrypt
-from sqlalchemy import Column, Integer, String
+from sqlalchemy import Column, Integer, String, ForeignKey
+from sqlalchemy.orm import relationship
 import datetime
 import jwt
+import random
+import string
 
 from nomad import infrastructure, config, utils
 
@@ -25,8 +28,9 @@ from .base import Base
 class Session(Base):  # type: ignore
     __tablename__ = 'sessions'
 
-    token = Column(String, primary_key=True)
-    user_id = Column(String)
+    token = Column(String)
+    user_id = Column(String, ForeignKey('users.user_id'), primary_key=True)
+    user = relationship('User')
 
 
 class LoginException(Exception):
@@ -52,20 +56,51 @@ class User(Base):  # type: ignore
     affiliation = Column(String)
     password = Column(String)
 
+    _token_chars = string.ascii_uppercase + string.ascii_lowercase + string.digits
+
     def __repr__(self):
         return '<User(email="%s")>' % self.email
 
-    def _hash_password(self, password):
-        assert False, 'Login functions are done by the NOMAD-coe repository GUI'
-        # password_hash = bcrypt.encrypt(password, ident='2y')
-        # self.password = password_hash
+    @staticmethod
+    def create_user(email: str, password: str, crypted: bool, **kwargs):
+        repo_db = infrastructure.repository_db
+        repo_db.begin()
+        try:
+            user = User(email=email, **kwargs)
+            repo_db.add(user)
+            user.set_password(password, crypted)
+
+            # TODO this has to change, e.g. trade for JWTs
+            token = ''.join(random.choices(User._token_chars, k=64))
+            repo_db.add(Session(token=token, user=user))
+
+            repo_db.commit()
+            return user
+        except Exception as e:
+            repo_db.rollback()
+            utils.get_logger('__name__').error('could not create user', email=email, exc_info=e)
+            raise e
+
+    def update(self, crypted: bool = True, password: str = None, **kwargs):
+        repo_db = infrastructure.repository_db
+        repo_db.begin()
+        try:
+            if password is not None:
+                self.set_password(password, crypted=crypted)
+
+            for key in kwargs:
+                setattr(self, key, kwargs.get(key))
+
+            repo_db.commit()
+        except Exception as e:
+            repo_db.rollback()
+            utils.get_logger('__name__').error(
+                'could not edit user', email=self.email, user_id=self.user_id, exc_info=e)
+            raise e
 
     def _verify_password(self, password):
         return bcrypt.verify(password, self.password)
 
-    def _generate_auth_token(self, expiration=600):
-        assert False, 'Login functions are done by the NOMAD-coe repository GUI'
-
     @staticmethod
     def from_user_id(user_id) -> 'User':
         return infrastructure.repository_db.query(User).get(user_id)
@@ -90,6 +125,20 @@ class User(Base):  # type: ignore
             config.services.api_secret, 'HS256').decode('utf-8')
         return token, expires_at
 
+    def set_password(self, password: str, crypted: bool):
+        """
+        Sets the users password. With ``crypted=True`` password is supposed to
+        be already bcrypted and 2y-indented.
+        """
+        if password is None:
+            return
+
+        if crypted:
+            self.password = password
+        else:
+            password_hash = bcrypt.encrypt(password, ident='2y')
+            self.password = password_hash
+
     @property
     def token(self):
         return self.get_auth_token().decode('utf-8')
diff --git a/tests/conftest.py b/tests/conftest.py
index 0788ed9852aebba639f6be843a19b8adac205239..02350c7c5b706b10eed2e5f0d29d6178ca4b8007 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -264,6 +264,8 @@ def postgres(postgres_infra):
     """ Provides a clean coe repository db per function. Clears db before test. """
     # do not wonder, this will not setback the id counters
     postgres_infra.execute('TRUNCATE uploads CASCADE;')
+    postgres_infra.execute('DELETE FROM sessions WHERE user_id >= 4;')
+    postgres_infra.execute('DELETE FROM users WHERE user_id >= 4;')
     yield postgres_infra
 
 
diff --git a/tests/test_api.py b/tests/test_api.py
index 1d382784edf7639e70278f4bb8acdae164b41e12..48e5c61aff6a5492b54f57c2a3fb51517dd3dcfa 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -19,6 +19,7 @@ import base64
 import zipfile
 import io
 import inspect
+from passlib.hash import bcrypt
 
 from nomad import config, coe_repo
 from nomad.files import UploadFiles, PublicUploadFiles
@@ -112,7 +113,9 @@ class TestAuth:
     def test_get_user(self, client, test_user_auth, test_user: User, no_warn):
         rv = client.get('/auth/user', headers=test_user_auth)
         assert rv.status_code == 200
-        user = json.loads(rv.data)
+        self.assert_user(client, json.loads(rv.data))
+
+    def assert_user(self, client, user):
         for key in ['first_name', 'last_name', 'email', 'token']:
             assert key in user
 
@@ -125,6 +128,49 @@ class TestAuth:
     def test_signature_token(self, test_user_signature_token, no_warn):
         assert test_user_signature_token is not None
 
+    def test_put_user(self, client, postgres, admin_user_auth):
+        rv = client.put(
+            '/auth/user', headers=admin_user_auth,
+            content_type='application/json', data=json.dumps(dict(
+                email='test@email.com', last_name='Tester', first_name='Testi',
+                password=bcrypt.encrypt('test_password', ident='2y'))))
+
+        assert rv.status_code == 200
+        self.assert_user(client, json.loads(rv.data))
+
+    def test_put_user_admin_only(self, client, test_user_auth):
+        rv = client.put(
+            '/auth/user', headers=test_user_auth,
+            content_type='application/json', data=json.dumps(dict(
+                email='test@email.com', last_name='Tester', first_name='Testi',
+                password=bcrypt.encrypt('test_password', ident='2y'))))
+        assert rv.status_code == 401
+
+    def test_put_user_required_field(self, client, admin_user_auth):
+        rv = client.put(
+            '/auth/user', headers=admin_user_auth,
+            content_type='application/json', data=json.dumps(dict(
+                email='test@email.com', password=bcrypt.encrypt('test_password', ident='2y'))))
+        assert rv.status_code == 400
+
+    def test_post_user(self, client, postgres, admin_user_auth):
+        rv = client.put(
+            '/auth/user', headers=admin_user_auth,
+            content_type='application/json', data=json.dumps(dict(
+                email='test@email.com', last_name='Tester', first_name='Testi',
+                password=bcrypt.encrypt('test_password', ident='2y'))))
+
+        assert rv.status_code == 200
+        user = json.loads(rv.data)
+
+        rv = client.post(
+            '/auth/user', headers={'X-Token': user['token']},
+            content_type='application/json', data=json.dumps(dict(
+                last_name='Tester', first_name='Testi v.',
+                password=bcrypt.encrypt('test_password_changed', ident='2y'))))
+        assert rv.status_code == 200
+        self.assert_user(client, json.loads(rv.data))
+
 
 class TestUploads:
 
diff --git a/tests/test_coe_repo.py b/tests/test_coe_repo.py
index 20a481af81fd38ef691ef2858259a845c53d5d68..927af0b15b7d06c2ebcecc2bc8ff408c5005e4dd 100644
--- a/tests/test_coe_repo.py
+++ b/tests/test_coe_repo.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 import pytest
+from passlib.hash import bcrypt
 
 from nomad.coe_repo import User, Calc, Upload
 from nomad import processing, parsing, datamodel
@@ -117,6 +118,20 @@ def test_add_upload_with_metadata(processed, example_user_metadata):
         processed.upload_id, upload_with_metadata)
 
 
+@pytest.mark.parametrize('crypted', [True, False])
+def test_create_user(postgres, crypted):
+    password = bcrypt.encrypt('test_password', ident='2y') if crypted else 'test_password'
+    data = dict(
+        email='test@email.com', last_name='Teser', first_name='testi', password=password)
+
+    user = User.create_user(**data, crypted=crypted)
+
+    authenticated_user = User.verify_user_password('test@email.com', 'test_password')
+    assert authenticated_user is not None
+    assert user.user_id == authenticated_user.user_id
+    assert user.get_auth_token() is not None
+
+
 class TestDataSets:
 
     @pytest.fixture(scope='function')