diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py
index c442ea4293246de7b376b12ffc4fd4fefed8ab67..a53c6bd934cc0e3a5bdec8d79075cf91507f045b 100644
--- a/nomad/app/v1/routers/entries.py
+++ b/nomad/app/v1/routers/entries.py
@@ -48,8 +48,9 @@ from ..utils import (
     create_download_stream_zipped, create_download_stream_raw_file,
     DownloadItem, create_responses)
 from ..models import (
-    Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, WithQuery, WithQueryAndPagination, MetadataRequired,
-    MetadataResponse, Metadata, Files, Query, User, Owner,
+    Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation,
+    WithQuery, WithQueryAndPagination, MetadataRequired, MetadataResponse, Metadata,
+    MetadataEditRequest, MetadataEditRequestResponse, Files, Query, User, Owner,
     QueryParameters, metadata_required_parameters, files_parameters, metadata_pagination_parameters,
     HTTPExceptionModel)
 
@@ -280,6 +281,18 @@ _bad_path_response = status.HTTP_404_NOT_FOUND, {
     'model': HTTPExceptionModel,
     'description': strip('File or directory not found.')}
 
+_bad_edit_request = status.HTTP_400_BAD_REQUEST, {
+    'model': HTTPExceptionModel,
+    'description': strip('Edit request could not be executed.')}
+
+_bad_edit_request_authorization = status.HTTP_401_UNAUTHORIZED, {
+    'model': HTTPExceptionModel,
+    'description': strip('Not enough permissions to execute edit request.')}
+
+_bad_edit_request_empty_query = status.HTTP_404_NOT_FOUND, {
+    'model': HTTPExceptionModel,
+    'description': strip('No matching entries found.')}
+
 _raw_download_response = 200, {
     'content': {'application/zip': {}},
     'description': strip('''
@@ -1328,3 +1341,35 @@ async def post_entry_metadata_edit(
                 datamodel.Dataset.m_def.a_mongo.objects(dataset_id=dataset).delete()
 
     return data
+
+
+@router.post(
+    '/edit',
+    tags=[metadata_tag],
+    summary='Edit the user metadata of a set of entries',
+    response_model=MetadataEditRequestResponse,
+    response_model_exclude_unset=True,
+    response_model_exclude_none=True,
+    responses=create_responses(
+        _bad_edit_request, _bad_edit_request_authorization, _bad_edit_request_empty_query))
+async def post_entries_edit(
+        request: Request,
+        data: MetadataEditRequest,
+        user: User = Depends(create_user_dependency(required=True))):
+    '''
+    Updates the metadata of the specified entries.
+
+    **Note:**
+      - Only admins can edit some of the fields.
+      - Only entry level attributes (like `comment`, `references` etc.) can be set using
+        this endpoint; upload level attributes (like `upload_name`, `coauthors`, embargo
+        settings, etc) need to be set through the endpoint **uploads/upload_id/edit**.
+      - If the upload is published, the only operation permitted using this endpoint is to
+        edit the members of datasets you own.
+    '''
+    edit_request_json = await request.json()
+    response, status_code = proc.MetadataEditRequestHandler.edit_metadata(
+        edit_request_json=edit_request_json, upload_id=None, user=user)
+    if status_code != status.HTTP_200_OK and not data.verify_only:
+        raise HTTPException(status_code=status_code, detail=response.error)
+    return response
diff --git a/nomad/app/v1/routers/uploads.py b/nomad/app/v1/routers/uploads.py
index e0b54649af44c8f30ed14fbc8ea568b5b8de1f0d..fe3f68952e1952372cdf8a0461b1359dbd058ea2 100644
--- a/nomad/app/v1/routers/uploads.py
+++ b/nomad/app/v1/routers/uploads.py
@@ -975,13 +975,13 @@ async def post_upload_edit(
       - Only admins can edit some of the fields.
       - The embargo of a published upload is lifted by setting the `embargo_length` attribute
         to 0.
-      - If the upload is published, the only operation permitted for non-admin users is to
-        lift the embargo, i.e. set `embargo_length` to 0, and add/remove entries from the
-        user's datasets.
+      - If the upload is published, the only operations permitted using this endpoint is to
+        lift the embargo, i.e. set `embargo_length` to 0, and to edit the members of datasets
+        you own.
       - If a query is specified, it is not possible to edit upload level metadata (like
         `upload_name`, `coauthors`, etc.), as the purpose of queries is to select only a
         subset of the upload entries to edit, but changing upload level metadata would affect
-        all entries of the upload.
+        **all** entries of the upload.
     '''
     edit_request_json = await request.json()
     response, status_code = MetadataEditRequestHandler.edit_metadata(
diff --git a/tests/app/v1/routers/test_entries_edit.py b/tests/app/v1/routers/test_entries_edit.py
index 48456d383b969e8a19d067f2e963c79ec4ddaee8..3089a0a173944a59346c5c15e72274151c63da68 100644
--- a/tests/app/v1/routers/test_entries_edit.py
+++ b/tests/app/v1/routers/test_entries_edit.py
@@ -17,6 +17,7 @@
 #
 
 import pytest
+from datetime import datetime
 
 from nomad import utils
 from nomad.search import search
@@ -24,6 +25,9 @@ from nomad.datamodel import Dataset
 from nomad import processing as proc
 
 from tests.utils import ExampleData
+from tests.app.v1.routers.common import assert_response
+from tests.processing.test_edit_metadata import (
+    assert_metadata_edited, all_coauthor_entry_metadata, all_admin_entry_metadata)
 
 
 logger = utils.get_logger(__name__)
@@ -301,3 +305,122 @@ class TestEditRepo():
     def test_admin_only(self, other_test_user):
         rv = self.perform_edit(main_author=other_test_user.user_id)
         assert rv.status_code != 200
+
+
+@pytest.mark.parametrize('user, kwargs', [
+    pytest.param(
+        'test_user', dict(
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=all_coauthor_entry_metadata,
+            affected_upload_ids=['id_unpublished_w']),
+        id='edit-all'),
+    pytest.param(
+        'admin_user', dict(
+            query={'upload_id': 'id_published_w'},
+            owner='all',
+            metadata=all_admin_entry_metadata,
+            affected_upload_ids=['id_published_w']),
+        id='protected-admin'),
+    pytest.param(
+        'test_user', dict(
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=all_admin_entry_metadata,
+            expected_status_code=401),
+        id='protected-not-admin'),
+    pytest.param(
+        'admin_user', dict(
+            query={'upload_id': 'id_published_w'},
+            owner='all',
+            metadata=dict(comment='test comment'),
+            affected_upload_ids=['id_published_w']),
+        id='published-admin'),
+    pytest.param(
+        'test_user', dict(
+            query={'upload_id': 'id_published_w'},
+            metadata=dict(comment='test comment'),
+            expected_status_code=401),
+        id='published-not-admin'),
+    pytest.param(
+        None, dict(
+            owner='all',
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=dict(comment='test comment'),
+            expected_status_code=401),
+        id='no-credentials'),
+    pytest.param(
+        'invalid', dict(
+            owner='all',
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=dict(comment='test comment'),
+            expected_status_code=401),
+        id='invalid-credentials'),
+    pytest.param(
+        'other_test_user', dict(
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=dict(comment='test comment'),
+            expected_status_code=404),
+        id='no-access'),
+    pytest.param(
+        'other_test_user', dict(
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=dict(comment='test comment'),
+            affected_upload_ids=['id_unpublished_w'],
+            add_coauthor=True),
+        id='coauthor-access'),
+    pytest.param(
+        'test_user', dict(
+            query={'and': [{'upload_create_time:gt': '2021-01-01'}, {'published': False}]},
+            metadata=dict(comment='a test comment'),
+            affected_upload_ids=['id_unpublished_w']),
+        id='compound-query-ok'),
+    pytest.param(
+        'test_user', dict(
+            query={'upload_id': 'id_unpublished_w'},
+            metadata=dict(upload_name='a test name'),
+            expected_status_code=400),
+        id='query-cannot-edit-upload-data'),
+    pytest.param(
+        'test_user', dict(
+            query={'upload_create_time:lt': '2021-01-01'},
+            metadata=dict(comment='a test comment'),
+            expected_status_code=404),
+        id='query-no-results')])
+def test_post_entries_edit(
+        client, proc_infra, example_data_writeable, a_dataset, test_auth_dict, test_users_dict,
+        user, kwargs):
+    '''
+    Note, since the endpoint basically just forwards the request to
+    `MetadataEditRequestHandler.edit_metadata`, we only do very simple verification here,
+    the more extensive testnig is done in `tests.processing.test_edit_metadata`.
+    '''
+    user_auth, _token = test_auth_dict[user]
+    user = test_users_dict.get(user)
+    query = kwargs.get('query')
+    owner = kwargs.get('owner', 'visible')
+    metadata = kwargs.get('metadata')
+    entries = kwargs.get('entries')
+    entries_key = kwargs.get('entries_key')
+    verify_only = kwargs.get('verify_only', False)
+    expected_status_code = kwargs.get('expected_status_code', 200)
+    if expected_status_code == 200 and not verify_only:
+        affected_upload_ids = kwargs.get('affected_upload_ids')
+        expected_metadata = kwargs.get('expected_metadata', metadata)
+
+    add_coauthor = kwargs.get('add_coauthor', False)
+    if add_coauthor:
+        upload = proc.Upload.get(affected_upload_ids[0])
+        upload.edit_upload_metadata(
+            edit_request_json={'metadata': {'coauthors': user.user_id}}, user_id=upload.main_author)
+        upload.block_until_complete()
+
+    edit_request_json = dict(
+        query=query, owner=owner, metadata=metadata, entries=entries, entries_key=entries_key,
+        verify_only=verify_only)
+    url = 'entries/edit'
+    edit_start = datetime.utcnow().isoformat()[0:22]
+    response = client.post(url, headers=user_auth, json=edit_request_json)
+    assert_response(response, expected_status_code)
+    if expected_status_code == 200:
+        assert_metadata_edited(
+            user, None, query, metadata, entries, entries_key, verify_only,
+            expected_status_code, expected_metadata, affected_upload_ids, edit_start)
diff --git a/tests/processing/test_edit_metadata.py b/tests/processing/test_edit_metadata.py
index fb9a4c6ec8132330df3c09727a69c1e7e9747fef..7f9d573bac05695d611e232cbb369583ecb233f1 100644
--- a/tests/processing/test_edit_metadata.py
+++ b/tests/processing/test_edit_metadata.py
@@ -50,6 +50,9 @@ all_admin_metadata = dict(
     license='a license',
     main_author='lhofstadter')
 
+all_admin_entry_metadata = {
+    k: v for k, v in all_admin_metadata.items() if k not in _mongo_upload_metadata}
+
 
 def assert_edit_request(user, **kwargs):
     # Extract test parameters (lots of defaults)
@@ -136,6 +139,8 @@ def convert_to_comparable_value(quantity, value, from_format, user):
     '''
     if quantity.is_scalar:
         return convert_to_comparable_value_single(quantity, value, from_format, user)
+    if value is None and from_format == 'es':
+        return []
     if type(value) != list:
         value = [value]
     return [convert_to_comparable_value_single(quantity, v, from_format, user) for v in value]