diff --git a/docs/apis/api.md b/docs/apis/api.md
index 509d83f3ecb1903e3555a7f16959c982eefd9159..277a6e0ca67e3ea0716704bcf040aff20216fcd3 100644
--- a/docs/apis/api.md
+++ b/docs/apis/api.md
@@ -390,6 +390,21 @@ To use authentication in the dashboard, simply use the Authorize button. The
 dashboard GUI will manage the access token and use it while you try out the various
 operations.
 
+#### App token
+
+If the short-term expiration of the default *access token* does not suit your needs,
+you can request an *app token* with a user-defined expiration. For example, you can
+send the GET request `/auth/app_token?expires_in=86400` together with some way of
+authentication, e.g. header `Authorization: Bearer <access token>`. The API will return
+an app token, which is valid for 24 hours in subsequent request headers with the format
+`Authorization: Bearer <app token>`. The request will be declined if the expiration is
+larger than the maximum expiration defined by the API config.
+
+!!! warning
+    Despite the name, the app token is used to impersonate the user who requested it.
+    It does not discern between different uses and will only become invalid once it
+    expires (or when the API's secret is changed).
+
 ## Search for entries
 
 See [getting started](#getting-started) for a typical search example. Combine the [different
diff --git a/nomad/app/v1/routers/auth.py b/nomad/app/v1/routers/auth.py
index f5c88d6e6a47419e70ae8c7d05a9431ae58b120b..49da50eb461ff8c78b190eaeb0cb9aa76570ffc0 100644
--- a/nomad/app/v1/routers/auth.py
+++ b/nomad/app/v1/routers/auth.py
@@ -20,7 +20,7 @@ import hmac
 import hashlib
 import uuid
 import requests
-from typing import Callable, cast
+from typing import Callable, cast, Union
 from inspect import Parameter, signature
 from functools import wraps
 from fastapi import APIRouter, Depends, Query as FastApiQuery, Request, HTTPException, status
@@ -51,6 +51,10 @@ class SignatureToken(BaseModel):
     signature_token: str
 
 
+class AppToken(BaseModel):
+    app_token: str
+
+
 oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f'{root_path}/auth/token', auto_error=False)
 
 
@@ -64,7 +68,6 @@ def create_user_dependency(
     Creates a dependency for getting the authenticated user. The parameters define if
     the authentication is required or not, and which authentication methods are allowed.
     '''
-
     def user_dependency(**kwargs) -> User:
         user = None
         if basic_auth_allowed:
@@ -180,17 +183,26 @@ def _get_user_basic_auth(form_data: OAuth2PasswordRequestForm) -> User:
 def _get_user_bearer_token_auth(bearer_token: str) -> User:
     '''
     Verifies bearer_token (throwing exception if illegal value provided) and returns the
-    corresponding user object, or None, if no bearer_token provided.
+    corresponding user object, or None if no bearer_token provided.
     '''
-    if bearer_token:
-        try:
-            user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token))
+    if not bearer_token:
+        return None
+
+    try:
+        unverified_payload = jwt.decode(bearer_token, options={"verify_signature": False})
+        if unverified_payload.keys() == set(['user', 'exp']):
+            user = _get_user_from_simple_token(bearer_token)
             return user
-        except infrastructure.KeycloakError as e:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail=str(e), headers={'WWW-Authenticate': 'Bearer'})
-    return None
+    except jwt.exceptions.DecodeError:
+        pass  # token could be non-JWT, e.g. for testing
+
+    try:
+        user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token))
+        return user
+    except infrastructure.KeycloakError as e:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=str(e), headers={'WWW-Authenticate': 'Bearer'})
 
 
 def _get_user_upload_token_auth(upload_token: str) -> User:
@@ -227,21 +239,8 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
     corresponding user object, or None, if no upload_token provided.
     '''
     if signature_token:
-        try:
-            decoded = jwt.decode(signature_token, config.services.api_secret, algorithms=['HS256'])
-            return datamodel.User.get(user_id=decoded['user'])
-        except KeyError:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail='Token with invalid/unexpected payload.')
-        except jwt.ExpiredSignatureError:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail='Expired token.')
-        except jwt.InvalidTokenError:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail='Invalid token.')
+        user = _get_user_from_simple_token(signature_token)
+        return user
     elif request:
         auth_cookie = request.cookies.get('Authorization')
         if auth_cookie:
@@ -261,6 +260,28 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us
     return None
 
 
+def _get_user_from_simple_token(token):
+    '''
+    Verifies a simple token (throwing exception if illegal value provided) and returns the
+    corresponding user object, or None if no token was provided.
+    '''
+    try:
+        decoded = jwt.decode(token, config.services.api_secret, algorithms=['HS256'])
+        return datamodel.User.get(user_id=decoded['user'])
+    except KeyError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail='Token with invalid/unexpected payload.')
+    except jwt.ExpiredSignatureError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail='Expired token.')
+    except jwt.InvalidTokenError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail='Invalid token.')
+
+
 _bad_credentials_response = status.HTTP_401_UNAUTHORIZED, {
     'model': HTTPExceptionModel,
     'description': strip('''
@@ -287,7 +308,6 @@ async def get_token(form_data: OAuth2PasswordRequestForm = Depends()):
     You only need to provide `username` and `password` values. You can ignore the other
     parameters.
     '''
-
     try:
         access_token = infrastructure.keycloak.basicauth(
             form_data.username, form_data.password)
@@ -311,7 +331,6 @@ async def get_token_via_query(username: str, password: str):
     This is an convenience alternative to the **POST** version of this operation.
     It allows you to retrieve an *access token* by providing username and password.
     '''
-
     try:
         access_token = infrastructure.keycloak.basicauth(username, password)
     except infrastructure.KeycloakError:
@@ -328,21 +347,51 @@ async def get_token_via_query(username: str, password: str):
     tags=[default_tag],
     summary='Get a signature token',
     response_model=SignatureToken)
-async def get_signature_token(user: User = Depends(create_user_dependency())):
+async def get_signature_token(
+        user: Union[User, None] = Depends(create_user_dependency(required=True))):
     '''
     Generates and returns a signature token for the authenticated user. Authentication
     has to be provided with another method, e.g. access token.
     '''
+    signature_token = generate_simple_token(user.user_id, expires_in=10)
+    return {'signature_token': signature_token}
 
-    expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
-    signature_token = jwt.encode(
-        dict(user=user.user_id, exp=expires_at),
-        config.services.api_secret, 'HS256')
 
-    return {'signature_token': signature_token}
+@router.get(
+    '/app_token',
+    tags=[default_tag],
+    summary='Get an app token',
+    response_model=AppToken)
+async def get_app_token(
+        expires_in: int = FastApiQuery(gt=0, le=config.services.app_token_max_expires_in),
+        user: User = Depends(create_user_dependency(required=True))):
+    '''
+    Generates and returns an app token with the requested expiration time for the
+    authenticated user. Authentication has to be provided with another method,
+    e.g. access token.
+
+    This app token can be used like the access token (see `/auth/token`) on subsequent API
+    calls to authenticate you using the HTTP header `Authorization: Bearer <app token>`.
+    It is provided for user convenience as a shorter token with a user-defined (probably
+    longer) expiration time than the access token.
+    '''
+    app_token = generate_simple_token(user.user_id, expires_in)
+    return {'app_token': app_token}
+
+
+def generate_simple_token(user_id, expires_in: int):
+    '''
+    Generates and returns JWT encoding just user_id and expiration time, signed with the
+    API secret.
+    '''
+    expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)
+    payload = dict(user=user_id, exp=expires_at)
+    token = jwt.encode(payload, config.services.api_secret, 'HS256')
+    return token
 
 
 def generate_upload_token(user):
+    '''Generates and returns upload token for user.'''
     payload = uuid.UUID(user.user_id).bytes
     signature = hmac.new(
         bytes(config.services.api_secret, 'utf-8'),
diff --git a/nomad/config/models.py b/nomad/config/models.py
index 7630010c4917d6b2561603e0dd4cac94f91fc783..10f88a9c80826883bedea89b5f31c3e441e7538e 100644
--- a/nomad/config/models.py
+++ b/nomad/config/models.py
@@ -202,6 +202,10 @@ class Services(NomadSettings):
         Value that is used in `results` section Enum fields (e.g. system type, spacegroup, etc.)
         to indicate that the value could not be determined.
     ''')
+    app_token_max_expires_in = Field(1 * 24 * 60 * 60, description='''
+        Maximum expiration time for an app token in seconds. Requests with a higher value
+        will be declined.
+    ''')
 
 
 class Meta(NomadSettings):
diff --git a/tests/app/conftest.py b/tests/app/conftest.py
index 035d5de7a0608262872e12f901af1376e69e13f5..586fa3b16b29691ee5509ea446bf9a9057027f4f 100644
--- a/tests/app/conftest.py
+++ b/tests/app/conftest.py
@@ -21,34 +21,43 @@ from fastapi.testclient import TestClient
 
 from nomad.app.main import app
 from nomad.datamodel import User
-from nomad.app.v1.routers.auth import generate_upload_token
+from nomad.app.v1.routers.auth import generate_upload_token, generate_simple_token
 
 
-def create_auth_headers(user: User):
-    return {
-        'Authorization': 'Bearer %s' % user.user_id
-    }
+def create_auth_headers(token: str):
+    return {'Authorization': f'Bearer {token}'}
 
 
 @pytest.fixture(scope='module')
 def test_user_auth(test_user: User):
-    return create_auth_headers(test_user)
+    return create_auth_headers(test_user.user_id)
 
 
 @pytest.fixture(scope='module')
 def other_test_user_auth(other_test_user: User):
-    return create_auth_headers(other_test_user)
+    return create_auth_headers(other_test_user.user_id)
 
 
 @pytest.fixture(scope='module')
 def admin_user_auth(admin_user: User):
-    return create_auth_headers(admin_user)
+    return create_auth_headers(admin_user.user_id)
+
+
+@pytest.fixture(scope='module')
+def invalid_user_auth():
+    return create_auth_headers("invalid.bearer.token")
+
+
+@pytest.fixture(scope='module')
+def app_token_auth(test_user: User):
+    app_token = generate_simple_token(test_user.user_id, expires_in=3600)
+    return create_auth_headers(app_token)
 
 
 @pytest.fixture(scope='module')
 def test_auth_dict(
         test_user, other_test_user, admin_user,
-        test_user_auth, other_test_user_auth, admin_user_auth):
+        test_user_auth, other_test_user_auth, admin_user_auth, invalid_user_auth):
     '''
     Returns a dictionary of the form {user_name: (auth_headers, token)}. The key 'invalid'
     contains an example of invalid credentials, and the key None contains (None, None).
@@ -57,7 +66,7 @@ def test_auth_dict(
         'test_user': (test_user_auth, generate_upload_token(test_user)),
         'other_test_user': (other_test_user_auth, generate_upload_token(other_test_user)),
         'admin_user': (admin_user_auth, generate_upload_token(admin_user)),
-        'invalid': ({'Authorization': 'Bearer JUST-MADE-IT-UP'}, 'invalid.token'),
+        'invalid': (invalid_user_auth, 'invalid.upload.token'),
         None: (None, None)}
 
 
diff --git a/tests/app/v1/routers/test_auth.py b/tests/app/v1/routers/test_auth.py
index b82a2f73261eeba55e678bdfe8aeb8fa549a000e..9c394ca1ac85287e40aa4cbfbba707632c2377d3 100644
--- a/tests/app/v1/routers/test_auth.py
+++ b/tests/app/v1/routers/test_auth.py
@@ -46,3 +46,30 @@ def test_get_signature_token(client, test_user_auth):
     response = client.get('auth/signature_token', headers=test_user_auth)
     assert response.status_code == 200
     assert response.json().get('signature_token') is not None
+
+
+def test_get_signature_token_unauthorized(client, invalid_user_auth):
+    response = client.get('auth/signature_token', headers=None)
+    assert response.status_code == 401
+    response = client.get('auth/signature_token', headers=invalid_user_auth)
+    assert response.status_code == 401
+
+
+@pytest.mark.parametrize(
+    'expires_in, status_code',
+    [(0, 422), (30 * 60, 200), (2 * 60 * 60, 200), (25 * 60 * 60, 422), (None, 422)])
+def test_get_app_token(client, test_user_auth, expires_in, status_code):
+    response = client.get('auth/app_token', headers=test_user_auth,
+                          params={'expires_in': expires_in})
+    assert response.status_code == status_code
+    if status_code == 200:
+        assert response.json().get('app_token') is not None
+
+
+def test_get_app_token_unauthorized(client, invalid_user_auth):
+    response = client.get('auth/app_token', headers=None,
+                          params={'expires_in': 60})
+    assert response.status_code == 401
+    response = client.get('auth/app_token', headers=invalid_user_auth,
+                          params={'expires_in': 60})
+    assert response.status_code == 401
diff --git a/tests/app/v1/routers/test_users.py b/tests/app/v1/routers/test_users.py
index 092ea1379942982d40e97f93571419efa2058f47..05d4804a69ba59ed7f11bb486ff65611857d0202 100644
--- a/tests/app/v1/routers/test_users.py
+++ b/tests/app/v1/routers/test_users.py
@@ -18,7 +18,8 @@
 #
 
 import pytest
-from tests.conftest import test_users as conf_test_users, test_user_uuid as conf_test_user_uuid
+from tests.conftest import (test_users as conf_test_users,
+                            test_user_uuid as conf_test_user_uuid)
 
 
 def assert_user(user, expected_user):
@@ -27,9 +28,11 @@ def assert_user(user, expected_user):
     assert 'email' not in user
 
 
-def test_me(client, test_user_auth):
+def test_me(client, test_user_auth, app_token_auth):
     response = client.get('users/me', headers=test_user_auth)
     assert response.status_code == 200
+    response = client.get('users/me', headers=app_token_auth)
+    assert response.status_code == 200
 
 
 def test_me_auth_required(client):