diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000000000000000000000000000000000000..dacbe64483886a6df67119f059914ae26a367656
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,3 @@
+[flake8]
+max-line-length = 99
+exclude = venv,env,.tox,*/migrations/*,*/frontend/*,build/*,.venv
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..61296f1ceb1ecfdab53a2419900a3f188429fb6a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,115 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+.static_storage/
+.media/
+local_settings.py
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+*.sqlite3
+
+# IDE files
+.idea
+.vscode
+
+# Secrets
+secrets.env
+
+# mac temp files
+.DS_store
diff --git a/apigatewayauth/api_gateway_auth.py b/apigatewayauth/api_gateway_auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..f99f69d12a1317ee96913433bcea658d403275cc
--- /dev/null
+++ b/apigatewayauth/api_gateway_auth.py
@@ -0,0 +1,56 @@
+from typing import Optional, Set
+from dataclasses import dataclass
+from rest_framework.request import Request
+from identitylib.identifiers import Identifier
+
+from rest_framework import authentication
+from rest_framework.exceptions import AuthenticationFailed
+
+
+@dataclass(eq=True)
+class APIGatewayAuthenticationDetails:
+    """
+    A dataclass representing the authentication information passed from the API Gateway.
+
+    """
+
+    principal_identifier: Identifier
+    scopes: Set[str]
+    app_id: Optional[str] = None
+    client_id: Optional[str] = None
+
+
+class APIGatewayAuthentication(authentication.BaseAuthentication):
+    """
+    An Authentication provider which interprets the headers provided by the API Gateway.
+
+    The Card API expects to only be deployed behind the API Gateway, and therefore relies on
+    the fact that the headers provided are authoritative.
+
+    """
+
+    def authenticate(self, request: Request):
+        if not request.META.get('HTTP_X_API_ORG_NAME', None):
+            # bail early if we look like we're not being called by the API Gateway
+            return None
+        if not request.META.get('HTTP_X_API_OAUTH2_USER', None):
+            raise AuthenticationFailed('Could not authenticate using x-api-* headers')
+
+        try:
+            principal_identifier = Identifier.from_string(
+                request.META['HTTP_X_API_OAUTH2_USER'],
+                find_by_alias=True
+            )
+        except Exception:
+            raise AuthenticationFailed('Invalid principal identifier')
+
+        auth = APIGatewayAuthenticationDetails(
+            principal_identifier=principal_identifier,
+            scopes=set(filter(bool, request.META.get('HTTP_X_API_OAUTH2_SCOPE', '').split(' '))),
+            # the following will only be populated for confidential clients
+            app_id=request.META.get('HTTP_X_API_DEVELOPER_APP_ID', None),
+            client_id=request.META.get('HTTP_X_API_OAUTH2_CLIENT_ID', None)
+        )
+        # the first item in the tuple represents the 'user' which we don't have when we've
+        # used the API Gateway for authentication.
+        return None, auth
diff --git a/apigatewayauth/openapi.py b/apigatewayauth/openapi.py
new file mode 100644
index 0000000000000000000000000000000000000000..eceedde0042781f4e28b4047b38adf9326069c46
--- /dev/null
+++ b/apigatewayauth/openapi.py
@@ -0,0 +1,37 @@
+from api.scopes import SCOPES_TO_DESCRIPTION
+
+API_SERVICE_CLIENT_CREDENTIALS = 'API Service OAuth2 Client Credentials'
+API_SERVICE_ACCESS_CODE = 'API Service OAuth2 Access Code'
+
+
+SECURITY_DEFINITIONS = {
+    API_SERVICE_CLIENT_CREDENTIALS: {
+        'type': 'oauth2',
+        'description': (
+            'Allows authentication using client credentials obtained from the API Service'
+        ),
+        'flow': 'application',  # should be `clientCredentials` when we update to OpenApi 3.0
+        'tokenUrl': 'https://<gateway_host>/oauth/client_credential/accesstoken'
+                    '?grant_type=client_credentials',
+        'scopes': SCOPES_TO_DESCRIPTION
+    },
+    API_SERVICE_ACCESS_CODE: {
+        'type': 'oauth2',
+        'flow': 'accessCode',
+        'authorizationUrl': 'https://<gateway_host>/oauth2/v1/auth',
+        'tokenUrl': 'https://<gateway_host>/oauth2/v1/token',
+        'scopes': SCOPES_TO_DESCRIPTION
+    }
+}
+
+
+def any_api_service_security_method_with_scopes(*scopes):
+    """
+    Helper method which returns security definitions for any API Service security
+    method with the given scopes.
+
+    """
+    return [
+        {security_method: list(scopes)}
+        for security_method in SECURITY_DEFINITIONS.keys()
+    ]
diff --git a/apigatewayauth/permissions.py b/apigatewayauth/permissions.py
new file mode 100644
index 0000000000000000000000000000000000000000..88b281702f63eb86212edeeed698e595756dca7b
--- /dev/null
+++ b/apigatewayauth/permissions.py
@@ -0,0 +1,162 @@
+from typing import Set
+from django.core.cache import cache
+from rest_framework import permissions
+from identitylib.identifiers import IdentifierSchemes
+
+from ucamlookup.utils import get_connection
+from ucamlookup.ibisclient import PersonMethods, IbisException
+
+from logging import getLogger
+
+from .api_gateway_auth import APIGatewayAuthenticationDetails
+from .permissions_spec import get_groups_with_permission, get_principals_with_permission
+
+
+LOG = getLogger(__name__)
+
+
+class Disallowed(permissions.BasePermission):
+    """
+    A permissions class which disallows all access, this is used as the default permissions
+    class to stop routes being added which accidentally expose data.
+
+    """
+    def has_permission(self, request, view):
+        return False
+
+    def has_object_permission(self, request, view, obj):
+        return False
+
+
+class IsResourceOwningPrincipal(permissions.BasePermission):
+    """
+    A permissions class which ensures that the client represents a principal who is limited
+    to accessing only resources they own.
+
+    """
+
+    message = 'Please authenticate as the owning user using the API Gateway.'
+
+    @staticmethod
+    def get_queryset_for_principal(request, base_object):
+        """
+        A helper method which filters the given object's queryset to the owning principal
+        if required
+
+        """
+        if not getattr(request, 'should_limit_to_resource_owning_principal', False):
+            return base_object.objects.all()
+        if not isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails):
+            return base_object.objects.none()
+
+        if not callable(getattr(base_object, 'get_queryset_for_principal', None)):
+            raise ValueError(
+                f'{base_object} does not implement get_queryset_for_principal'
+            )
+        return base_object.get_queryset_for_principal(request.auth.principal_identifier)
+
+    def has_permission(self, request, view):
+        # we cannot determine permissions ownership permissions on list routes, but
+        # rely on `get_queryset_for_principal` to be used to filter the queryset
+        # appropriately
+        if isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails):
+            setattr(request, 'should_limit_to_resource_owning_principal', True)
+            return True
+        return False
+
+    def has_object_permission(self, request, view, obj):
+        if not isinstance(getattr(request, 'auth', None), APIGatewayAuthenticationDetails):
+            return False
+
+        is_owned_by = getattr(obj, 'is_owned_by', None)
+        if not callable(is_owned_by):
+            LOG.warn(f'Unable to determine ownership for {obj}')
+            return False
+
+        return is_owned_by(request.auth.principal_identifier)
+
+
+def HasAnyScope(*required_scopes):
+
+    class HasAnyScopesPermission(permissions.BasePermission):
+        """
+        A permissions class which enforces that the given request has any of the given scopes.
+
+        """
+
+        message = f'Request must have one of the following scope(s) {" ".join(required_scopes)}'
+
+        def has_permission(self, request, view):
+            request_scopes = getattr(getattr(request, 'auth', {}), 'scopes', set())
+            return len(set(required_scopes) & request_scopes) > 0
+
+        def has_object_permission(self, request, view, obj):
+            return self.has_permission(request, view)
+
+    return HasAnyScopesPermission
+
+
+def SpecifiedPermission(permission: str):
+
+    class HasSpecifiedPermission(permissions.BasePermission):
+        """
+        A permissions class which ensures that the principal has the correct permissions
+        within the permissions specification.
+
+        """
+
+        message = f'Authenticated principal does not have permission {permission}'
+
+        def has_permission(self, request, view):
+            principals_with_permission = get_principals_with_permission(permission)
+            if request.auth.principal_identifier in principals_with_permission:
+                return True
+
+            if request.auth.principal_identifier.scheme != IdentifierSchemes.CRSID:
+                LOG.warn('Can only determine group membership for principals identified by CRSID')
+                return
+
+            # special case for people identified by crsid - check whether they are in a
+            # lookup group within our list of identities for permission
+            groups_with_permission = get_groups_with_permission(permission)
+            lookup_group_ids = set([
+                identifier.value for identifier in groups_with_permission
+                if identifier.scheme == IdentifierSchemes.LOOKUP_GROUP
+            ])
+
+            if not lookup_group_ids:
+                return False
+
+            return self.is_in_any_lookup_group(
+                request.auth.principal_identifier.value, lookup_group_ids
+            )
+
+        def has_object_permission(self, request, view, obj):
+            return self.has_permission(request, view)
+
+        def is_in_any_lookup_group(self, crsid: str, group_ids: Set[str]):
+            """
+            Determine whether a person identified by a crsid is a member of any of the lookup
+            groups provided. Caches the result for 5 minutes to speed up subsequent responses.
+
+            """
+            cache_key = f'{crsid}_in_{",".join(group_ids)}'
+            cached_response = cache.get(cache_key)
+            if cached_response is not None:
+                return cached_response
+
+            is_in_group = False
+            try:
+                group_list = PersonMethods(
+                    get_connection()
+                ).getGroups(scheme="crsid", identifier=crsid)
+                is_in_group = any(
+                    (group.groupid for group in group_list if group.groupid in group_ids)
+                )
+            except IbisException as err:
+                LOG.warn(f'Failed to get Lookup groups for {crsid} due to {err}')
+
+            cache.set(cache_key, is_in_group, timeout=600)
+            return is_in_group
+
+    return HasSpecifiedPermission
diff --git a/apigatewayauth/permissions_spec.py b/apigatewayauth/permissions_spec.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a45147a9670749714537c8811c91128ef76fc63
--- /dev/null
+++ b/apigatewayauth/permissions_spec.py
@@ -0,0 +1,75 @@
+from typing import List, Dict, Set
+from django.core.cache import cache
+from django.conf import settings
+from rest_framework.request import Request
+from yaml import safe_load
+from geddit import geddit
+from identitylib.identifiers import Identifier
+
+from .api_gateway_auth import APIGatewayAuthenticationDetails
+from .permissions import SpecifiedPermission
+
+
+PERMISSIONS_CACHE_KEY = '__PERMISSION_CACHE__'
+
+
+def get_permission_spec() -> Dict[str, Dict[str, List]]:
+    """
+    Returns the permissions spec - expected to be a mapping of permission name to a list of
+    identifiers representing people or service identities who have the given permission.
+
+    """
+
+    cached_entry = cache.get(PERMISSIONS_CACHE_KEY)
+    if cached_entry:
+        return cached_entry
+
+    permissions_spec = safe_load(geddit(settings.PERMISSIONS_SPECIFICATION_URL))
+    # Cache the permissions spec for 12 hours. We expect the Card API to be redeployed
+    # when the permissions spec is updated, so it is safe to cache this for a long period
+    # of time.
+    cache.set(PERMISSIONS_CACHE_KEY, permissions_spec, timeout=43200)
+
+    return permissions_spec
+
+
+def get_principals_with_permission(permission_name: str) -> Set[Identifier]:
+    """
+    Returns a set of Identifiers reprenting people or service identities which have the
+    given permission according to the permissions specification.
+
+    """
+    return set(
+        map(
+            lambda identifier_str: Identifier.from_string(identifier_str, find_by_alias=True),
+            get_permission_spec().get(permission_name, {}).get('principals', []),
+        )
+    )
+
+
+def get_groups_with_permission(permission_name: str) -> Set[Identifier]:
+    """
+    Returns a set of Identifiers representing groups which indicate membership gives the
+    users or service accounts the specified permission.
+
+    """
+    return set(
+        map(
+            lambda identifier_str: Identifier.from_string(identifier_str, find_by_alias=True),
+            get_permission_spec().get(permission_name, {}).get('groups', []),
+        )
+    )
+
+
+def get_permissions_for_request(request: Request):
+    """
+    Returns a list of permissions which the request's principal has been granted.
+    The permissions will be key any of the keys from the permissions spec, where the
+    principal is included within the principals or groups sections.
+
+    """
+
+    return [
+        permission_name for permission_name in get_permission_spec().keys() if
+        SpecifiedPermission(permission_name)().has_permission(request, None)
+    ] if isinstance(request.auth, APIGatewayAuthenticationDetails) else []
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6aebb919f5f9ae1d3ba9ff8b21fd3704da08019c
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+django~=3.2.7
+djangorestframework
+geddit
+PyYAML
+ucam-identitylib==1.0.1
+django-ucamlookup~=3.0.5
\ No newline at end of file