diff --git a/north/app/main.py b/north/app/main.py
index cdba8ead14d7f7cb2c8d7a7f3afdd618cb91a8ec..ba52e61742a0e21e7911ec0d7ecdddcd3237b0a7 100644
--- a/north/app/main.py
+++ b/north/app/main.py
@@ -25,6 +25,7 @@ from north import config
 
 from .routes import instances
 from .routes import tools
+from .routes import auth
 
 app = FastAPI(
     title='NOMAD remote tools hub API',
@@ -35,6 +36,7 @@ app = FastAPI(
 
 app.include_router(tools.router, prefix='/tools')
 app.include_router(instances.router, prefix='/instances')
+app.include_router(auth.router, prefix='/auth')
 
 
 # A default 404 response with a link to the API dashboard for convinience
diff --git a/north/app/models.py b/north/app/models.py
index eca8c02e28d2a929072ab1303e820632bd3972fe..404f92fa827642b079ea837b5d1b6ce1e68cb18a 100644
--- a/north/app/models.py
+++ b/north/app/models.py
@@ -16,7 +16,7 @@
 # limitations under the License.
 #
 
-from typing import List, Dict, Optional
+from typing import List, Dict, Optional, Any
 from pydantic import BaseModel, validator, Field
 
 # TODO This exemplifies pydantic models a little bit. But, this is just for demonstration.
@@ -55,3 +55,9 @@ class InstanceModel(BaseModel):
     def validate_tool_name(cls, tool_name):  # pylint: disable=no-self-argument
         assert tool_name in tools_map, 'Tool must exist.'
         return tool_name
+
+
+class TokenModel(BaseModel):
+    token: str
+    claims: Optional[Dict[str, Any]]
+    exprires: Optional[int]
diff --git a/north/app/routes/auth.py b/north/app/routes/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..026727d9a6e52f3e7bdbb06c277c457f2dd3468c
--- /dev/null
+++ b/north/app/routes/auth.py
@@ -0,0 +1,86 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# 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.
+#
+
+from north.app.models import TokenModel
+from typing import Optional
+from fastapi import APIRouter, Request
+from fastapi.exceptions import HTTPException
+from fastapi.params import Depends
+
+from north.auth import create_token, verify_token, TokenError
+
+router = APIRouter()
+router_tag = 'auth'
+
+
+def token(request: Request) -> Optional[TokenModel]:
+    authorization = request.headers.get('Authorization')
+    if authorization is None:
+        return None
+
+    bearer, token = authorization.split(' ', 1)
+    if bearer != 'Bearer':
+        raise HTTPException(status_code=401, detail='Invalid authentication scheme.')
+
+    try:
+        return verify_token(token, claims=['channel'])
+    except TokenError as e:
+        raise HTTPException(status_code=401, detail=e.msg) from e
+
+
+def token_required(request: Request):
+    authorization = request.headers.get('Authorization')
+    if authorization is None:
+        raise HTTPException(status_code=401, detail='Authorization is required.')
+
+    return token(request)
+
+
+@router.get(
+    '/channel/{channel}',
+    tags=[router_tag])
+async def authorize_channel(channel: int, token=Depends(token_required)):
+    '''
+    Verifies channel token in the header and either raises HTTP 200 or 401.
+    This is used by a proxy to authorize access to a docker container channel before
+    relaying requests via that channel.
+    '''
+    if 'channel' not in token.claims:
+        raise HTTPException(
+            status_code=401, detail='Need to authorize with a channel token.')
+
+    if token.claims['channel'] != channel:
+        raise HTTPException(
+            status_code=401, detail='You are not authorized to access this channel')
+
+    return 'authorized'
+
+
+@router.post(
+    '/refresh',
+    tags=[router_tag],
+    response_model=TokenModel,
+    response_model_exclude_unset=True,
+    response_model_exclude_none=True)
+async def refresh_token(token: TokenModel):
+    '''
+    Returns a new token. It will have the same claims as the given token, but a new
+    expiry time.
+    '''
+    token = verify_token(token.token, claims=None)
+    return create_token(claims=token.claims)
diff --git a/north/app/routes/instances.py b/north/app/routes/instances.py
index a3b8a3550ef43c4e63795249db64f4434661dfe4..081adb1f8179cc29c92543005ff1ac0ed205f130 100644
--- a/north/app/routes/instances.py
+++ b/north/app/routes/instances.py
@@ -18,7 +18,9 @@
 
 from typing import List
 from fastapi import APIRouter
+from fastapi.params import Depends
 
+from north.app.routes.auth import token
 from north.app.models import InstanceModel
 
 router = APIRouter()
@@ -42,6 +44,8 @@ async def get_instances():
     response_model=InstanceModel,
     response_model_exclude_unset=True,
     response_model_exclude_none=True)
-async def post_instances(instance: InstanceModel):
+async def post_instances(
+        instance: InstanceModel, token=Depends(token)):
     ''' Create a new tool instance. '''
+
     return instance
diff --git a/north/auth.py b/north/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..189c49d4917039a173af2b1d7448fbde7c86fe15
--- /dev/null
+++ b/north/auth.py
@@ -0,0 +1,119 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# 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.
+#
+
+'''
+This module provide the necessary token create/verify functionality. Currently there
+are two types of tokens, launch tokens and channel tokens.
+
+The channel token allows to authorize access to a running container. The container is
+accessed through a numbered channel via the north proxy. The token will be issued
+when creating the instances.
+
+The launch token allows to authorize that a container can be created and that certain
+resources can be accessed through the container. The launch token might be optional.
+In the NOMAD Oasis use-case, launch tokens might be issued by the NOMAD API to authorize
+clients to create containers that access a users files and directories.
+
+All tokens can exprire. Token can be re-issued by the north auth API.
+'''
+
+from north.app.models import TokenModel
+from typing import List, Dict, Any, Optional
+import jwt
+from datetime import datetime, timedelta
+
+from north import config
+
+
+class TokenError(Exception):
+    def __init__(self, msg):
+        super().__init__(msg)
+        self.msg = msg
+
+
+def create_launch_token(paths: List[str], duration: timedelta = timedelta(minutes=10)) -> TokenModel:
+    '''
+    Creates a JWT token that contains a claim on a set of paths and is valid for the given
+    duration.
+
+    This could be issued for example by the NOMAD uploads API after authorizing access
+    to certain paths. It could be used by 3rd-party products to authorize access to
+    certain paths.
+
+    # TODO more complex claims. E.g. Read/Write access, tool to launch, etc.
+
+    Returns: The token as a string.
+    '''
+
+    claims = {
+        'paths': paths
+    }
+    return create_token(claims, duration)
+
+
+def create_channel_token(channel: int, duration: timedelta = timedelta(minutes=10)) -> TokenModel:
+    '''
+    Creates a JWT token that contains a claim on the given channel for the given
+    duration starting now.
+
+    Returns: The token as a string.
+    '''
+
+    claims = {
+        'channel': channel
+    }
+    return create_token(claims, duration)
+
+
+def create_token(claims: Optional[Dict[str, Any]], duration: timedelta = timedelta(minutes=10)) -> TokenModel:
+    ''' Creates a JWT token with the given claims. '''
+    claims_to_encode = dict(exp=datetime.utcnow() + duration, **claims)
+    token = jwt.encode(claims_to_encode, config.secret, algorithm='HS256')
+
+    return TokenModel(token=token, expires=duration.seconds, claims=claims)
+
+
+def verify_token(token: str, claims: List[str] = None) -> TokenModel:
+    '''
+    Verifies the given token. Checks its expiry and if the given claims are present.
+
+    Returns: A dictionary with the given claims as keys and values taken from the token
+        payload.
+
+    Raises:
+        TokenError: If the token could not be decoded, is experied, or did not contain
+            the necessary claims.
+    '''
+
+    try:
+        encoded_claims = jwt.decode(
+            token, config.secret, algorithms=['HS256'],
+            options=dict(require=['exp'] + claims if claims else []))
+    except jwt.ExpiredSignatureError as e:
+        raise TokenError('The token is expired.') from e
+    except jwt.DecodeError as e:
+        raise TokenError('The token could not be decoded.') from e
+
+    expires = datetime.utcfromtimestamp(encoded_claims['exp']) - datetime.utcnow()
+    token_claims = None
+    if claims is not None:
+        token_claims = {
+            key: value for key, value in encoded_claims.items() if key in claims
+        }
+
+    return TokenModel(token=token, claims=token_claims, expires=expires.seconds)
diff --git a/requirements.txt b/requirements.txt
index 4fca4c4feab7c21db14d7545bbf70176bc15d103..42b5e0dab4b37573b8403fdc4441221a43537852 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -11,3 +11,4 @@ types-PyYAML
 types-requests
 uvicorn
 docker
+PyJWT
diff --git a/tests/app/routes/test_auth.py b/tests/app/routes/test_auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..12eefabaf6e6da9a9ee554ec7fbcd9cdf1863f70
--- /dev/null
+++ b/tests/app/routes/test_auth.py
@@ -0,0 +1,49 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# 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.
+#
+
+from datetime import timedelta
+
+from north.auth import create_channel_token
+
+
+def test_valid_channel(api):
+    token = create_channel_token(channel=1)
+    response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}'))
+    assert response.status_code == 200
+
+
+def test_wrong_channel(api):
+    token = create_channel_token(channel=2)
+    response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}'))
+    assert response.status_code == 401
+
+
+def test_missing_channel_token(api):
+    response = api.get('auth/channel/1')
+    assert response.status_code == 401
+
+
+def test_bad_channel_token(api):
+    response = api.get('auth/channel/1', headers=dict(Authorization='Bearer not a token'))
+    assert response.status_code == 401
+
+
+def test_expired_channel_token(api):
+    token = create_channel_token(channel=1, duration=-timedelta(days=1))
+    response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}'))
+    assert response.status_code == 401
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..f06fb4d6e8ccc7b93728342183b61ca7d578324b
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,40 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# 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.
+#
+
+from datetime import timedelta
+import pytest
+
+from north.auth import TokenError, create_channel_token, create_launch_token, create_token, verify_token
+
+
+def test_channel_token():
+    token = create_channel_token(channel=1)
+    token_model = verify_token(token.token, claims=['channel'])
+    assert token_model.claims['channel'] == 1
+
+
+def test_launch_token():
+    token = create_launch_token(paths=['/my/folder'])
+    token_model = verify_token(token.token, claims=['paths'])
+    assert token_model.claims['paths'] == ['/my/folder']
+
+
+def test_token_expiry():
+    token = create_token(claims={}, duration=-timedelta(days=1))
+    with pytest.raises(TokenError):
+        verify_token(token.token, claims=[])