diff --git a/app/__init__.py b/app/__init__.py
index da498c39d7145c9f3e9b010f17beb22450013539..4649a0908076dd39fd1c5ac7df05364b0806c70b 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -37,6 +37,9 @@ def create_app(config_name):
     from .admin import bp as admin_blueprint
     app.register_blueprint(admin_blueprint, url_prefix='/admin')
 
+    from .api import bp as api_blueprint
+    app.register_blueprint(api_blueprint, url_prefix='/api')
+
     from .auth import bp as auth_blueprint
     app.register_blueprint(auth_blueprint, url_prefix='/auth')
 
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f47235ea9b9801dd6e4f0048d5f3ca368a77b0a1
--- /dev/null
+++ b/app/api/__init__.py
@@ -0,0 +1,27 @@
+from flask import Blueprint
+from flask_restx import Api
+
+from .jobs import ns as jobs_ns
+from .tokens import ns as tokens_ns
+
+bp = Blueprint('api', __name__)
+authorizations = {
+    'basicAuth': {
+        'type': 'basic'
+    },
+    'apiKey': {
+        'type': 'apiKey',
+        'in': 'header',
+        'name': 'Authorization'
+    }
+}
+api = Api(
+    bp,
+    authorizations=authorizations,
+    description='An API to interact with nopaque',
+    title='nopaque API',
+    version='1.0'
+)
+
+api.add_namespace(jobs_ns)
+api.add_namespace(tokens_ns)
diff --git a/app/api/auth.py b/app/api/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..24e862eaedca68f110f4d74192ab55935f6ae463
--- /dev/null
+++ b/app/api/auth.py
@@ -0,0 +1,30 @@
+from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
+from sqlalchemy import or_
+from werkzeug.http import HTTP_STATUS_CODES
+from ..models import User
+
+basic_auth = HTTPBasicAuth()
+token_auth = HTTPTokenAuth()
+
+
+@basic_auth.verify_password
+def verify_password(email_or_username, password):
+    user = User.query.filter(or_(User.username == email_or_username,
+                                 User.email == email_or_username.lower())).first()
+    if user and user.verify_password(password):
+        return user
+
+
+@basic_auth.error_handler
+def basic_auth_error(status):
+    return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status
+
+
+@token_auth.verify_token
+def verify_token(token):
+    return User.check_token(token) if token else None
+
+
+@token_auth.error_handler
+def token_auth_error(status):
+    return {'error': HTTP_STATUS_CODES.get(status, 'Unknown error')}, status
diff --git a/app/api/jobs.py b/app/api/jobs.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ade2fe440d615dfd6bf373d5b05967f56355d78
--- /dev/null
+++ b/app/api/jobs.py
@@ -0,0 +1,18 @@
+from flask_restx import Namespace, Resource
+from .auth import token_auth
+from ..models import Job
+
+
+ns = Namespace('jobs', description='Job operations')
+
+
+@ns.route('/')
+class JobList(Resource):
+    '''Shows a list of all jobs, and lets you POST to add new job'''
+
+    @ns.doc(security='apiKey')
+    @token_auth.login_required
+    def get(self):
+        '''List all jobs'''
+        jobs = Job.query.all()
+        return [job.to_dict(include_relationships=False) for job in jobs]
diff --git a/app/api/tokens.py b/app/api/tokens.py
new file mode 100644
index 0000000000000000000000000000000000000000..c12b6048e6139f871b46c4762d6974e354ed30b3
--- /dev/null
+++ b/app/api/tokens.py
@@ -0,0 +1,27 @@
+from flask_restx import Namespace, Resource
+from .auth import basic_auth, token_auth
+from .. import db
+
+
+ns = Namespace('token', description='Authentication token operations')
+
+
+@ns.route('/')
+class Token(Resource):
+    '''Get or revoke a user authentication token'''
+
+    @ns.doc(security='basicAuth')
+    @basic_auth.login_required
+    def post(self):
+        '''Get user token'''
+        token = basic_auth.current_user().get_token()
+        db.session.commit()
+        return {'token': 'Bearer ' + token}
+
+    @ns.doc(security='apiKey')
+    @token_auth.login_required
+    def delete(self):
+        '''Revoke user token'''
+        token_auth.current_user().revoke_token()
+        db.session.commit()
+        return '', 204
diff --git a/app/models.py b/app/models.py
index 1bae80603ab68d6c82288c7c70931c21c60a3b54..abc6b9f2ece4259d7ebfc240cc1c60b7058bccd7 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timedelta
 from flask import current_app, url_for
 from flask_login import UserMixin, AnonymousUserMixin
 from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer
@@ -6,6 +6,7 @@ from time import sleep
 from werkzeug.security import generate_password_hash, check_password_hash
 import xml.etree.ElementTree as ET
 from . import db, login_manager
+import base64
 import logging
 import os
 import shutil
@@ -127,6 +128,8 @@ class User(UserMixin, db.Model):
                                                       default='end')
     setting_job_status_site_notifications = db.Column(db.String(16),
                                                       default='all')
+    token = db.Column(db.String(32), index=True, unique=True)
+    token_expiration = db.Column(db.DateTime)
     username = db.Column(db.String(64), unique=True, index=True)
     # Relationships
     corpora = db.relationship('Corpus', backref='creator', lazy='dynamic',
@@ -157,8 +160,8 @@ class User(UserMixin, db.Model):
             'role_id': self.role_id,
             'confirmed': self.confirmed,
             'email': self.email,
-            'last_seen': self.last_seen.isoformat(),
-            'member_since': self.member_since.isoformat(),
+            'last_seen': self.last_seen.isoformat() + 'Z',
+            'member_since': self.member_since.isoformat() + 'Z',
             'settings': {'dark_mode': self.setting_dark_mode,
                          'job_status_mail_notifications':
                              self.setting_job_status_mail_notifications,
@@ -262,6 +265,25 @@ class User(UserMixin, db.Model):
         shutil.rmtree(self.path, ignore_errors=True)
         db.session.delete(self)
 
+    def get_token(self, expires_in=3600):
+        now = datetime.utcnow()
+        if self.token and self.token_expiration > now + timedelta(seconds=60):
+            return self.token
+        self.token = base64.b64encode(os.urandom(24)).decode('utf-8')
+        self.token_expiration = now + timedelta(seconds=expires_in)
+        db.session.add(self)
+        return self.token
+
+    def revoke_token(self):
+        self.token_expiration = datetime.utcnow() - timedelta(seconds=1)
+
+    @staticmethod
+    def check_token(token):
+        user = User.query.filter_by(token=token).first()
+        if user is None or user.token_expiration < datetime.utcnow():
+            return None
+        return user
+
 
 class AnonymousUser(AnonymousUserMixin):
     '''
@@ -453,9 +475,9 @@ class Job(db.Model):
             'url': self.url,
             'id': self.id,
             'user_id': self.user_id,
-            'creation_date': self.creation_date.isoformat(),
+            'creation_date': self.creation_date.isoformat() + 'Z',
             'description': self.description,
-            'end_date': self.end_date.isoformat() if self.end_date else None,
+            'end_date': self.end_date.isoformat() + 'Z' if self.end_date else None,
             'service': self.service,
             'service_args': self.service_args,
             'service_version': self.service_version,
@@ -589,11 +611,11 @@ class Corpus(db.Model):
             'url': self.url,
             'id': self.id,
             'user_id': self.user_id,
-            'creation_date': self.creation_date.isoformat(),
+            'creation_date': self.creation_date.isoformat() + 'Z',
             'current_nr_of_tokens': self.current_nr_of_tokens,
             'description': self.description,
             'status': self.status,
-            'last_edited_date': self.last_edited_date.isoformat(),
+            'last_edited_date': self.last_edited_date.isoformat() + 'Z',
             'max_nr_of_tokens': self.max_nr_of_tokens,
             'title': self.title,
         }
diff --git a/migrations/versions/c384d7b3268a_.py b/migrations/versions/c384d7b3268a_.py
new file mode 100644
index 0000000000000000000000000000000000000000..aaffd8d2bc30a84e0845ad5deb2db8fae99e6af5
--- /dev/null
+++ b/migrations/versions/c384d7b3268a_.py
@@ -0,0 +1,32 @@
+"""empty message
+
+Revision ID: c384d7b3268a
+Revises: 55d2b1a82ba9
+Create Date: 2021-09-14 09:11:45.409350
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'c384d7b3268a'
+down_revision = '55d2b1a82ba9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('token', sa.String(length=32), nullable=True))
+    op.add_column('users', sa.Column('token_expiration', sa.DateTime(), nullable=True))
+    op.create_index(op.f('ix_users_token'), 'users', ['token'], unique=True)
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_index(op.f('ix_users_token'), table_name='users')
+    op.drop_column('users', 'token_expiration')
+    op.drop_column('users', 'token')
+    # ### end Alembic commands ###
diff --git a/requirements.txt b/requirements.txt
index 5faa3cee8249c20ce4cdc0dfdf35dda476e2ee2b..d4e23941d131370f65e47569c7e5697ae781a7a4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,10 +3,12 @@ docker
 eventlet
 Flask~=1.1.0
 Flask-Assets
+Flask-HTTPAuth
 Flask-Login
 Flask-Mail
 Flask-Migrate
 Flask-Paranoid
+Flask-RESTX
 Flask-SocketIO~=5.0.0
 Flask-SQLAlchemy
 Flask-WTF