diff --git a/nomad/api/__init__.py b/nomad/api/__init__.py
index 5d04f83592f42a433b1569500e5f6b9a461bc866..328d5ba761f5810abe7faad996908127ab1c9dfc 100644
--- a/nomad/api/__init__.py
+++ b/nomad/api/__init__.py
@@ -29,7 +29,7 @@ There is a separate documentation for the API endpoints from a client perspectiv
 .. automodule:: nomad.api.admin
 """
 from .app import app
-from . import info, auth, admin, upload, repo, archive, raw
+from . import info, auth, admin, upload, repo, archive, raw, mirror
 
 
 @app.before_first_request
diff --git a/nomad/api/admin.py b/nomad/api/admin.py
index 270951858d240b05ec67daf48c2bc6597df343b5..c2745391d1de50eeb5fd27725dcc033470734611 100644
--- a/nomad/api/admin.py
+++ b/nomad/api/admin.py
@@ -12,13 +12,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from flask import g, request
+from flask import request
 from flask_restplus import abort, Resource, fields
 
 from nomad import infrastructure, config
 
 from .app import api
-from .auth import login_really_required
+from .auth import admin_login_required
 
 
 ns = api.namespace('admin', description='Administrative operations')
@@ -29,7 +29,7 @@ class AdminResetResource(Resource):
     @api.doc('exec_reset_command')
     @api.response(200, 'Reset performed')
     @api.response(400, 'Reset not available/disabled')
-    @login_really_required
+    @admin_login_required
     def post(self):
         """
         The ``reset`` command will attempt to clear the contents of all databased and
@@ -37,9 +37,6 @@ class AdminResetResource(Resource):
 
         Nomad can be configured to disable reset and the command might not be available.
         """
-        if not g.user.is_admin:
-            abort(401, message='Only the admin user can perform reset.')
-
         if config.services.disable_reset:
             abort(400, message='Operation is disabled')
 
@@ -53,7 +50,7 @@ class AdminRemoveResource(Resource):
     @api.doc('exec_remove_command')
     @api.response(200, 'Remove performed')
     @api.response(400, 'Remove not available/disabled')
-    @login_really_required
+    @admin_login_required
     def post(self):
         """
         The ``remove``command will attempt to remove all databases. Expect the
@@ -61,8 +58,6 @@ class AdminRemoveResource(Resource):
 
         Nomad can be configured to disable remove and the command might not be available.
         """
-        if not g.user.is_admin:
-            abort(401, message='Only the admin user can perform remove.')
 
         if config.services.disable_reset:
             abort(400, message='Operation is disabled')
@@ -77,21 +72,20 @@ pidprefix_model = api.model('PidPrefix', {
 })
 
 
+# TODO remove after migration
 @ns.route('/pidprefix')
 class AdminPidPrefixResource(Resource):
     @api.doc('exec_pidprefix_command')
     @api.response(200, 'Pid prefix set')
     @api.response(400, 'Bad pid prefix data')
     @api.expect(pidprefix_model)
-    @login_really_required
+    @admin_login_required
     def post(self):
         """
         The ``pidprefix``command will set the pid counter to the given value.
 
         This might be useful while migrating data with old pids.
         """
-        if not g.user.is_admin:
-            abort(401, message='Only the admin user can perform remove.')
 
         infrastructure.set_pid_prefix(**request.get_json())
 
diff --git a/nomad/api/auth.py b/nomad/api/auth.py
index 165be56f84e3910515dd336ed14c7bb650ac433d..f8d401ca9b17557028d4b27ff310bc998f20954d 100644
--- a/nomad/api/auth.py
+++ b/nomad/api/auth.py
@@ -128,6 +128,24 @@ def login_really_required(func):
     return wrapper
 
 
+def admin_login_required(func):
+    """
+    A decorator for API endpoint implementations that should only work for the admin user.
+    """
+    @api.response(401, 'Authentication required or not authorized as admin user. Only admin can access this endpoint.')
+    @api.doc(security=list(api.authorizations.keys()))
+    @login_really_required
+    def wrapper(*args, **kwargs):
+        if not g.user.is_admin:
+            abort(401, message='Only the admin user can perform reset.')
+        else:
+            return func(*args, **kwargs)
+
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    return wrapper
+
+
 ns = api.namespace(
     'auth',
     description='Authentication related endpoints.')
diff --git a/nomad/api/common.py b/nomad/api/common.py
index 9ee259a5704b93072ee71fdc00c6863075068359..87fdbdb5f37727e4b903599d7c9f19c0b405e0d2 100644
--- a/nomad/api/common.py
+++ b/nomad/api/common.py
@@ -52,3 +52,14 @@ def calc_route(ns, prefix: str = ''):
             })(func)
         )
     return decorator
+
+
+def upload_route(ns, prefix: str = ''):
+    """ A resource decorator for /<upload> based routes. """
+    def decorator(func):
+        ns.route('%s/<string:upload_id>' % prefix)(
+            api.doc(params={
+                'upload_id': 'The unique id for the requested upload.'
+            })(func)
+        )
+    return decorator
diff --git a/nomad/api/mirror.py b/nomad/api/mirror.py
new file mode 100644
index 0000000000000000000000000000000000000000..30ccb9ca56079273c58cd989fd82b6c36aeaa9de
--- /dev/null
+++ b/nomad/api/mirror.py
@@ -0,0 +1,62 @@
+# Copyright 2018 Markus Scheidgen
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an"AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+The mirror API of the nomad@FAIRDI APIs. Allows to export upload metadata.
+"""
+
+from flask_restplus import Resource, abort, fields
+
+from nomad import processing
+
+from .app import api
+from .auth import admin_login_required
+from .common import upload_route
+
+ns = api.namespace('mirror', description='Export upload (and all calc) metadata.')
+
+
+mirror_upload_model = api.model('MirrorUpload', {
+    'upload_id': fields.String(description='The id of the exported upload'),
+    'upload': fields.String(description='The upload metadata as mongoengine json string'),
+    'calcs': fields.List(fields.String, description='All upload calculation metadata as mongoengine json strings'),
+    'upload_files_path': fields.String(description='The path to the local uploads file folder')
+})
+
+
+@upload_route(ns)
+class MirrorUploadResource(Resource):
+    @api.response(400, 'Not available for the given upload, e.g. upload not published.')
+    @api.response(404, 'The upload does not exist')
+    @api.marshal_with(mirror_upload_model, skip_none=True, code=200, description='Upload exported')
+    @api.doc('get_mirror_upload')
+    @admin_login_required
+    def get(self, upload_id):
+        """
+        Export upload (and all calc) metadata for mirrors.
+        """
+        try:
+            upload = processing.Upload.get(upload_id)
+        except KeyError:
+            abort(404, message='Upload with id %s does not exist.' % upload_id)
+
+        if not upload.published:
+            abort(400, message='Only published uploads can be exported')
+
+        return {
+            'upload_id': upload.upload_id,
+            'upload': upload.to_json(),
+            'calcs': [calc.to_json() for calc in upload.calcs],
+            'upload_files_path': upload.upload_files.os_path
+        }, 200
diff --git a/nomad/api/upload.py b/nomad/api/upload.py
index 225cdd3a873869734f96b493c31a7f1e41ab5a49..d435626ce473c0aa863fc7a92926acf6eb3ab231 100644
--- a/nomad/api/upload.py
+++ b/nomad/api/upload.py
@@ -32,7 +32,7 @@ from nomad.processing import ProcessAlreadyRunning
 
 from .app import api, with_logger, RFC3339DateTime
 from .auth import login_really_required
-from .common import pagination_request_parser, pagination_model
+from .common import pagination_request_parser, pagination_model, upload_route
 
 
 ns = api.namespace(
@@ -342,8 +342,7 @@ class ProxyUpload:
         return self.upload.__getattribute__(name)
 
 
-@ns.route('/<string:upload_id>')
-@api.doc(params={'upload_id': 'The unique id for the requested upload.'})
+@upload_route(ns)
 class UploadResource(Resource):
     @api.doc('get_upload')
     @api.response(404, 'Upload does not exist')
diff --git a/tests/test_api.py b/tests/test_api.py
index be639bda8bd6f4e14bc0413f6ba2b6249f4d5266..cf723f7860c26d0ea7d153c4a41ffe4ddf0a212c 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1022,6 +1022,21 @@ class TestRaw(UploadFilesBasedTests):
         assert rv.status_code == 404
 
 
+class TestMirror:
+
+    def test_upload(self, client, published, admin_user_auth, no_warn):
+        url = '/mirror/%s' % published.upload_id
+        rv = client.get(url, headers=admin_user_auth)
+        assert rv.status_code == 200
+
+        data = json.loads(rv.data)
+        assert data['upload_id'] == published.upload_id
+        assert json.loads(data['upload'])['_id'] == published.upload_id
+        assert Upload.from_json(data['upload']).upload_id == published.upload_id
+        assert len(data['calcs']) == len(published.calcs)
+        assert data['upload_files_path'] == published.upload_files.os_path
+
+
 def test_docs(client):
     rv = client.get('/docs/index.html')
     rv = client.get('/docs/introduction.html')