diff --git a/MANIFEST.in b/MANIFEST.in
index e5d4319faf6f97d547fcde6f9a4565b0119ae425..24151b168f290ebc06752be21d8d85b7a72aa5de 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -5,3 +5,4 @@ include tests-requirements.txt
 include arkindex/documents/*.xsl
 recursive-include arkindex/templates *.html
 recursive-include arkindex/templates *.json
+recursive-include arkindex/templates *.txt
diff --git a/arkindex/documents/api.py b/arkindex/documents/api.py
index c3c14b40e808d9bcedd85c1f715187ddc9c2364c..f1ea80bd70b54381cf5a3040d9453d750a00576d 100644
--- a/arkindex/documents/api.py
+++ b/arkindex/documents/api.py
@@ -88,6 +88,14 @@ class CorpusList(ListAPIView):
         return Corpus.objects.readable(self.request.user)
 
 
+class PageDetails(RetrieveAPIView):
+    """
+    Get details for a specific page
+    """
+    serializer_class = PageLightSerializer
+    queryset = Page.objects.select_related('zone__image__server')
+
+
 class SurfaceDetails(RetrieveAPIView):
     """
     Get details for a specific surface
diff --git a/arkindex/documents/models.py b/arkindex/documents/models.py
index 467a165dec0ae1ab251da857c06405eb8a2015c7..93ef6128b65b9d90966a8b07aa1a6a7b39c85013 100644
--- a/arkindex/documents/models.py
+++ b/arkindex/documents/models.py
@@ -349,6 +349,11 @@ class Page(Element):
         return False
 
     def __eq__(self, other):
+        # Prevent AttributeError when a Page is an Element and its subclass has not been used
+        if not isinstance(other, Page):
+            if not isinstance(other, Element) and hasattr(other, 'page'):
+                return False
+            other = other.page
         return self.page_type == other.page_type \
             and self.nb == other.nb \
             and self.direction == other.direction \
diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py
index df0b1491a39a082828e42367c805fe7887455559..fd74750f5c00525abcebd32e3a7a2a9d9864e593 100644
--- a/arkindex/project/api_v1.py
+++ b/arkindex/project/api_v1.py
@@ -5,12 +5,14 @@ from arkindex.documents.api import \
     VolumeManifest, ActManifest, \
     PageAnnotationList, PageActAnnotationList, SurfaceAnnotationList, \
     TranscriptionSearch, ActSearch, TranscriptionSearchAnnotationList, \
-    ActEdit, TranscriptionCreate, TranscriptionBulk, SurfaceDetails
+    ActEdit, TranscriptionCreate, TranscriptionBulk, PageDetails, SurfaceDetails
 from arkindex.dataimport.api import \
     DataImportsList, DataImportDetails, DataImportFailures, \
     DataFileList, DataFileRetrieve, DataFileUpload, \
     GitRepositoryImportHook, RepositoryList, AvailableRepositoriesList, RepositoryRetrieve, RepositoryStartImport
-from arkindex.users.api import ProvidersList, CredentialsList, CredentialsRetrieve
+from arkindex.users.api import \
+    ProvidersList, CredentialsList, CredentialsRetrieve, \
+    UserRetrieve, UserCreate, UserEmailLogin, UserEmailVerification
 
 api = [
 
@@ -22,8 +24,9 @@ api = [
     url(r'elements/(?P<pk>[\w\-]+)/$',
         RelatedElementsList.as_view(), name='related-elements'),
     url(r'elements/$', ElementsList.as_view(), name='elements'),
+    url(r'page/(?P<pk>[\w\-]+)/?$', PageDetails.as_view(), name='page-details'),
     url(r'surface/(?P<pk>[\w\-]+)/?$', SurfaceDetails.as_view(), name='surface-details'),
-    url(r'corpus/$', CorpusList.as_view(), name='corpus'),
+    url(r'corpus/?$', CorpusList.as_view(), name='corpus'),
 
     # Manifests
     url(r'^manifest/(?P<pk>[\w\-]+)/pages/?$',
@@ -78,7 +81,7 @@ api = [
     url(r'^imports/repos/search/(?P<pk>[\w\-]+)/?$',
         AvailableRepositoriesList.as_view(),
         name='available-repositories'),
-    url(r'^imports/(?P<pk>[\w\-]+)$', DataImportDetails.as_view(), name='import-details'),
+    url(r'^imports/(?P<pk>[\w\-]+)/?$', DataImportDetails.as_view(), name='import-details'),
     url(r'^imports/(?P<pk>[\w\-]+)/failures$', DataImportFailures.as_view(), name='import-failures'),
     url(r'^imports/files/(?P<pk>[\w\-]+)$', DataFileList.as_view(), name='file-list'),
     url(r'^imports/file/(?P<pk>[\w\-]+)$', DataFileRetrieve.as_view(), name='file-retrieve'),
@@ -89,4 +92,10 @@ api = [
     url(r'^oauth/providers/?$', ProvidersList.as_view(), name='providers-list'),
     url(r'^oauth/credentials/?$', CredentialsList.as_view(), name='credentials-list'),
     url(r'^oauth/credentials/(?P<pk>[\w\-]+)/?$', CredentialsRetrieve.as_view(), name='credentials-retrieve'),
+
+    # Authentication
+    url(r'^user/?$', UserRetrieve.as_view(), name='user-retrieve'),
+    url(r'^user/new/?$', UserCreate.as_view(), name='user-new'),
+    url(r'^user/login/?$', UserEmailLogin.as_view(), name='user-login'),
+    url(r'^user/token/?$', UserEmailVerification.as_view(), name='user-token'),
 ]
diff --git a/arkindex/project/settings.py b/arkindex/project/settings.py
index a6bc942a2dbb0c9fdecffa6319a52db20a702dd9..927dc10a885b46e93c95dd9513c9b947a626bb92 100644
--- a/arkindex/project/settings.py
+++ b/arkindex/project/settings.py
@@ -14,6 +14,15 @@ import os
 import logging
 import tempfile
 
+
+def env2list(env_name, separator=','):
+    '''
+    Load env variable as a list
+    '''
+    value = os.environ.get(env_name)
+    return value and value.split(separator) or []
+
+
 # Admins in charge
 ADMINS = [
     ('Bastien', 'abadie@teklia.com'),
@@ -33,8 +42,7 @@ SECRET_KEY = os.environ.get('SECRET_KEY', 'jf0w^y&ml(caax8f&a1mub)(js9(l5mhbbhos
 ARKINDEX_ENV = os.environ.get('ARKINDEX_ENV', 'dev')
 DEBUG = ARKINDEX_ENV == 'dev'
 
-hosts = os.environ.get('ALLOWED_HOSTS')
-ALLOWED_HOSTS = hosts and hosts.split(',') or []
+ALLOWED_HOSTS = env2list('ALLOWED_HOSTS')
 
 # Required for django-debug-toolbar
 INTERNAL_IPS = ['127.0.0.1', '127.0.1.1']
@@ -53,6 +61,7 @@ INSTALLED_APPS = [
     'rest_framework',
     'rest_framework.authtoken',
     'webpack_loader',
+    'corsheaders',
 
     # Our apps
     'arkindex.images',
@@ -62,6 +71,7 @@ INSTALLED_APPS = [
 ]
 
 MIDDLEWARE = [
+    'corsheaders.middleware.CorsMiddleware',
     'django.middleware.security.SecurityMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
@@ -148,11 +158,6 @@ USE_L10N = True
 USE_TZ = True
 
 
-# Cookies
-CSRF_COOKIE_NAME = 'arkindex.csrf'
-SESSION_COOKIE_NAME = 'arkindex.auth'
-
-
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/1.11/howto/static-files/
 FRONTEND_DIR = os.environ.get(
@@ -344,6 +349,18 @@ if os.environ.get('EMAIL_HOST'):
 else:
     EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
 
+# Cookies
+CSRF_COOKIE_NAME = 'arkindex.csrf'
+CSRF_TRUSTED_ORIGINS = env2list('CSRF_TRUSTED_ORIGINS')
+CSRF_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
+
+SESSION_COOKIE_NAME = 'arkindex.auth'
+SESSION_COOKIE_DOMAIN = os.environ.get('COOKIE_DOMAIN')
+
+CORS_ORIGIN_WHITELIST = env2list('CORS_ORIGIN_WHITELIST')
+CORS_ALLOW_CREDENTIALS = True
+CORS_URLS_REGEX = r'^/api/.*$'
+
 # Optional unit tests runner with code coverage
 try:
     import django_nose # noqa
@@ -369,11 +386,3 @@ try:
     INSTALLED_APPS.append('debug_toolbar')
 except ImportError:
     pass
-
-try:
-    import corsheaders # noqa
-    MIDDLEWARE.insert(0, 'corsheaders.middleware.CorsMiddleware')
-    INSTALLED_APPS.append('corsheaders')
-    CORS_ORIGIN_WHITELIST = ('localhost:5000', )
-except ImportError:
-    pass
diff --git a/arkindex/templates/registration/verification_email.html b/arkindex/templates/registration/verification_email.html
new file mode 100644
index 0000000000000000000000000000000000000000..153f3c31474ba0ed00a76d61bf2509f24f616620
--- /dev/null
+++ b/arkindex/templates/registration/verification_email.html
@@ -0,0 +1,10 @@
+{% autoescape off %}
+To verify your email address for your Arkindex account, click the link below:
+
+{{ url }}
+
+If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead.
+
+--
+Teklia
+{% endautoescape %}
diff --git a/arkindex/templates/registration/verification_email_subject.txt b/arkindex/templates/registration/verification_email_subject.txt
new file mode 100644
index 0000000000000000000000000000000000000000..acfcefad2d11891cd7833990222d548eed481fc4
--- /dev/null
+++ b/arkindex/templates/registration/verification_email_subject.txt
@@ -0,0 +1 @@
+Arkindex email verification
diff --git a/arkindex/users/admin.py b/arkindex/users/admin.py
index deacd14b35625118611314a4ea70aae2183191dc..e386a8577bb4324d06142161074a631eefb7b4ea 100644
--- a/arkindex/users/admin.py
+++ b/arkindex/users/admin.py
@@ -67,7 +67,7 @@ class UserAdmin(BaseUserAdmin):
     list_display = ('email', 'is_admin')
     list_filter = ('is_admin',)
     fieldsets = (
-        (None, {'fields': ('email', 'password')}),
+        (None, {'fields': ('email', 'verified_email', 'password')}),
         ('Permissions', {'fields': ('is_admin',)}),
     )
     # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin
diff --git a/arkindex/users/api.py b/arkindex/users/api.py
index a25010fd133641cf50bf20fb757a00fde145bdb0..7c6a914491e6fc4957672b7b29891f6306f5a75c 100644
--- a/arkindex/users/api.py
+++ b/arkindex/users/api.py
@@ -1,7 +1,22 @@
-from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView
+from django.urls import reverse
+from django.core.mail import send_mail
+from django.contrib.auth import login, logout
+from django.contrib.auth.tokens import default_token_generator
+from django.views.generic import RedirectView
+from django.template.loader import render_to_string
+from django.utils.http import urlsafe_base64_encode
+from rest_framework.generics import ListAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, CreateAPIView
 from rest_framework.permissions import IsAuthenticated
+from rest_framework.exceptions import AuthenticationFailed, ValidationError
+from arkindex.documents.models import Corpus
 from arkindex.users.providers import oauth_providers
-from arkindex.users.serializers import OAuthCredentialsSerializer, OAuthProviderClassSerializer
+from arkindex.users.models import User
+from arkindex.users.serializers import \
+    OAuthCredentialsSerializer, OAuthProviderClassSerializer, UserSerializer, NewUserSerializer, EmailLoginSerializer
+import urllib.parse
+import logging
+
+logger = logging.getLogger(__name__)
 
 
 class ProvidersList(ListAPIView):
@@ -31,3 +46,86 @@ class CredentialsRetrieve(RetrieveDestroyAPIView):
     def perform_destroy(self, instance):
         instance.provider_class(request=self.request, credentials=instance).disconnect()
         super().perform_destroy(instance)
+
+
+class UserRetrieve(RetrieveUpdateDestroyAPIView):
+    permission_classes = (IsAuthenticated, )
+    serializer_class = UserSerializer
+
+    def get_object(self):
+        return self.request.user
+
+    def perform_destroy(self, instance):
+        logout(self.request)
+
+
+class UserCreate(CreateAPIView):
+    serializer_class = NewUserSerializer
+
+    def perform_create(self, serializer):
+        user = serializer.save()
+        login(self.request, user)
+
+        corpus = Corpus.objects.create(name=user.email)
+        corpus.corpus_right.create(user=user, can_write=True, can_admin=True)
+
+        activation_url = '{}?{}'.format(
+            self.request.build_absolute_uri(reverse('api:user-token')),
+            urllib.parse.urlencode({
+                'email': user.email,
+                'token': default_token_generator.make_token(user),
+            }),
+        )
+        sent = send_mail(
+            # Subject cannot have any newlines
+            subject=''.join(render_to_string('registration/verification_email_subject.txt').splitlines()),
+            message=render_to_string(
+                'registration/verification_email.html',
+                context={
+                    'url': activation_url,
+                },
+                request=self.request,
+            ),
+            from_email=None,
+            recipient_list=[user.email],
+            fail_silently=True,
+        )
+        if sent == 0:
+            logger.error('Failed to send registration email to {}'.format(user.email))
+
+
+class UserEmailLogin(CreateAPIView):
+    serializer_class = EmailLoginSerializer
+
+    def perform_create(self, serializer):
+        try:
+            user = User.objects.get_by_natural_key(serializer.validated_data['email'])
+            assert user.has_usable_password()
+            assert user.check_password(serializer.validated_data['password'])
+        except (User.DoesNotExist, AssertionError):
+            raise AuthenticationFailed()
+        login(self.request, user)
+
+
+class UserEmailVerification(RedirectView):
+
+    def get_redirect_url(self):
+        if not all(arg in self.request.GET for arg in ('email', 'token')):
+            raise ValidationError()
+        try:
+            user = User.objects.get_by_natural_key(self.request.GET['email'])
+            assert default_token_generator.check_token(user, self.request.GET['token'])
+        except (User.DoesNotExist, AssertionError):
+            raise AuthenticationFailed()
+
+        user.verified_email = True
+        user.save()
+
+        if user.has_usable_password():
+            login(self.request, user)
+            return '/'
+
+        return reverse('password_reset_confirm', kwargs={
+            'uidb64': urlsafe_base64_encode(str(user.id).encode()).decode(),
+            'token': self.request.GET['token'],
+        })
diff --git a/arkindex/users/migrations/0005_user_verified_email.py b/arkindex/users/migrations/0005_user_verified_email.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8687a1d717a86ea250d1490b0bc344aaaf1262a
--- /dev/null
+++ b/arkindex/users/migrations/0005_user_verified_email.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1 on 2018-08-21 14:48
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0004_corpus_right'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='verified_email',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/arkindex/users/models.py b/arkindex/users/models.py
index 1863197b6be1c6c3907212f76900cd1a0eb8979b..1ea75d459ec756b61133063a79722facfcbdb803 100644
--- a/arkindex/users/models.py
+++ b/arkindex/users/models.py
@@ -13,6 +13,7 @@ class User(AbstractBaseUser):
     )
     is_active = models.BooleanField(default=True)
     is_admin = models.BooleanField(default=False)
+    verified_email = models.BooleanField(default=False)
 
     corpus = models.ManyToManyField('documents.Corpus', through='users.CorpusRight')
 
diff --git a/arkindex/users/serializers.py b/arkindex/users/serializers.py
index a786bc63cd96fe433b4d68d26a1d6b79b5ee2ab2..1bf657496abcaa8920f8f5827f179f82e5ffb580 100644
--- a/arkindex/users/serializers.py
+++ b/arkindex/users/serializers.py
@@ -1,5 +1,5 @@
 from rest_framework import serializers
-from arkindex.users.models import OAuthCredentials
+from arkindex.users.models import OAuthCredentials, User
 
 
 class OAuthCredentialsSerializer(serializers.ModelSerializer):
@@ -22,3 +22,44 @@ class OAuthProviderClassSerializer(serializers.Serializer):
     name = serializers.CharField(source='__name__')
     display_name = serializers.CharField()
     default_url = serializers.URLField(source='url')
+
+
+class UserSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = User
+        fields = (
+            'id',
+            'email',
+            'password',
+            'is_admin',
+        )
+        extra_kwargs = {
+            'id': {'read_only': True},
+            'email': {'read_only': True},
+            'password': {'write_only': True},
+            'is_admin': {'read_only': True},
+        }
+
+    def update(self, instance, validated_data):
+        if 'password' in validated_data:
+            instance.set_password(validated_data.pop('password'))
+        return super().update(instance, validated_data)
+
+
+class NewUserSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = User
+        fields = (
+            'email',
+        )
+
+    def create(self, validated_data):
+        return User.objects.create_user(validated_data['email'])
+
+
+class EmailLoginSerializer(serializers.Serializer):
+
+    email = serializers.EmailField()
+    password = serializers.CharField(write_only=True)
diff --git a/arkindex/users/tests/__init__.py b/arkindex/users/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/arkindex/users/tests/test_acl.py b/arkindex/users/tests/test_acl.py
new file mode 100644
index 0000000000000000000000000000000000000000..950f85631c26cd1d1dbb9767f4cee27d526527b9
--- /dev/null
+++ b/arkindex/users/tests/test_acl.py
@@ -0,0 +1,46 @@
+from django.test import TestCase
+from django.contrib.auth.models import AnonymousUser
+from arkindex.documents.models import Corpus
+from arkindex.users.models import User
+
+
+class TestACL(TestCase):
+    '''
+    Test Corpus ACL
+    '''
+
+    def setUp(self):
+        self.anon = AnonymousUser()
+        self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd')
+        self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd')
+
+        self.corpus_public = Corpus.objects.create(name='A Public', public=True)
+        self.corpus_private = Corpus.objects.create(name='B Private')
+        self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True)
+        self.corpus_hidden = Corpus.objects.create(name='C Hidden')
+
+    def test_anon(self):
+        # An anonymous user has only access to public
+        self.assertQuerysetEqual(
+            Corpus.objects.readable(self.anon),
+            [self.corpus_public.id],
+            transform=lambda x: x.id,
+        )
+
+    def test_user(self):
+        # An anonymous user has access to public & private
+        self.assertFalse(self.user.is_admin)
+        self.assertQuerysetEqual(
+            Corpus.objects.readable(self.user),
+            [self.corpus_public.id, self.corpus_private.id],
+            transform=lambda x: x.id,
+        )
+
+    def test_admin(self):
+        # An admin has access to all
+        self.assertTrue(self.admin.is_admin)
+        self.assertQuerysetEqual(
+            Corpus.objects.readable(self.admin),
+            Corpus.objects.order_by('name').values_list('id', flat=True),
+            transform=lambda x: x.id,
+        )
diff --git a/arkindex/users/tests.py b/arkindex/users/tests/test_django_views.py
similarity index 51%
rename from arkindex/users/tests.py
rename to arkindex/users/tests/test_django_views.py
index ed835110deadfa61c6ed5289abcdcd3e79a20e37..5b6176df2eefbd0e9eb98250586fe8319337ca37 100644
--- a/arkindex/users/tests.py
+++ b/arkindex/users/tests/test_django_views.py
@@ -3,15 +3,14 @@ from django.urls import reverse
 from django.core import mail
 from django.contrib import auth
 from arkindex.users.models import User
-from django.contrib.auth.models import AnonymousUser
-from arkindex.documents.models import Corpus
 from rest_framework.authtoken.models import Token
 
 
 class TestUsers(TestCase):
 
-    def setUp(self):
-        self.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
 
     def test_login_success(self):
         response = self.client.post(
@@ -50,45 +49,3 @@ class TestUsers(TestCase):
         Check creating a user automatically creates a token
         """
         self.assertEqual(Token.objects.filter(user=self.user).count(), 1)
-
-
-class TestACL(TestCase):
-    '''
-    Test Corpus ACL
-    '''
-
-    def setUp(self):
-        self.anon = AnonymousUser()
-        self.admin = User.objects.create_superuser('admin@address.com', 'P4$5w0Rd')
-        self.user = User.objects.create_user('user@address.com', 'P4$5w0Rd')
-
-        self.corpus_public = Corpus.objects.create(name='A Public', public=True)
-        self.corpus_private = Corpus.objects.create(name='B Private')
-        self.user.corpus_right.create(corpus=self.corpus_private, user=self.user, can_write=True)
-        self.corpus_hidden = Corpus.objects.create(name='C Hidden')
-
-    def test_anon(self):
-        # An anonymous user has only access to public
-        self.assertQuerysetEqual(
-            Corpus.objects.readable(self.anon),
-            [self.corpus_public.id],
-            transform=lambda x: x.id,
-        )
-
-    def test_user(self):
-        # An anonymous user has access to public & private
-        self.assertFalse(self.user.is_admin)
-        self.assertQuerysetEqual(
-            Corpus.objects.readable(self.user),
-            [self.corpus_public.id, self.corpus_private.id],
-            transform=lambda x: x.id,
-        )
-
-    def test_admin(self):
-        # An admin has access to all
-        self.assertTrue(self.admin.is_admin)
-        self.assertQuerysetEqual(
-            Corpus.objects.readable(self.admin),
-            Corpus.objects.order_by('name').values_list('id', flat=True),
-            transform=lambda x: x.id,
-        )
diff --git a/arkindex/users/tests/test_registration.py b/arkindex/users/tests/test_registration.py
new file mode 100644
index 0000000000000000000000000000000000000000..61ac1e30b0468433df0f48e61481f678844a8350
--- /dev/null
+++ b/arkindex/users/tests/test_registration.py
@@ -0,0 +1,106 @@
+from rest_framework.test import APITestCase
+from rest_framework import status
+from django.urls import reverse
+from django.core import mail
+from django.contrib import auth
+from django.contrib.auth.tokens import default_token_generator
+from arkindex.users.models import User
+import urllib.parse
+
+
+class TestRegistration(APITestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user('email@address.com', 'P4$5w0Rd')
+
+    def test_retrieve_requires_login(self):
+        response = self.client.get(reverse('api:user-retrieve'))
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_update_requires_login(self):
+        response = self.client.put(
+            reverse('api:user-retrieve'),
+            data={'password': 'N€wP4$5w0Rd'},
+            format='json',
+        )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_logout_requires_login(self):
+        response = self.client.delete(reverse('api:user-retrieve'))
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_retrieve(self):
+        self.client.force_login(self.user)
+        response = self.client.get(reverse('api:user-retrieve'))
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        data = response.json()
+        self.assertEqual(data['email'], 'email@address.com')
+        self.assertEqual(data['is_admin'], False)
+        self.assertNotIn('password', data)
+
+    def test_update(self):
+        self.client.force_login(self.user)
+        response = self.client.put(
+            reverse('api:user-retrieve'),
+            data={'password': 'N€wP4$5w0Rd'},
+            format='json',
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.user.refresh_from_db()
+        self.assertTrue(self.user.check_password('N€wP4$5w0Rd'))
+
+    def test_logout(self):
+        self.client.force_login(self.user)
+        response = self.client.delete(reverse('api:user-retrieve'))
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+        self.assertFalse(auth.get_user(self.client).is_authenticated)
+
+    def test_create(self):
+        response = self.client.post(
+            reverse('api:user-new'),
+            data={'email': 'newuser@example.com'},
+            format='json',
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertTrue(auth.get_user(self.client).is_authenticated)
+        self.assertEqual(auth.get_user(self.client).email, 'newuser@example.com')
+        self.assertFalse(User.objects.get_by_natural_key('newuser@example.com').verified_email)
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(mail.outbox[0].to, ['newuser@example.com'])
+
+    def test_login_failure(self):
+        response = self.client.post(
+            reverse('api:user-login'),
+            data={'email': 'email@address.com', 'password': 'wrongpassword'},
+            format='json',
+        )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertFalse(auth.get_user(self.client).is_authenticated)
+
+    def test_login(self):
+        response = self.client.post(
+            reverse('api:user-login'),
+            data={'email': 'email@address.com', 'password': 'P4$5w0Rd'},
+            format='json',
+        )
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertTrue(auth.get_user(self.client).is_authenticated)
+        self.assertEqual(auth.get_user(self.client).email, 'email@address.com')
+
+    def test_email_verification(self):
+        newuser = User.objects.create_user('newuser@example.com')
+        self.assertFalse(newuser.verified_email)
+        self.assertFalse(newuser.has_usable_password())
+
+        response = self.client.get('{}?{}'.format(
+            reverse('api:user-token'),
+            urllib.parse.urlencode({
+                'email': 'newuser@example.com',
+                'token': default_token_generator.make_token(newuser),
+            })),
+        )
+        self.assertEqual(response.status_code, status.HTTP_302_FOUND)
+
+        newuser.refresh_from_db()
+        self.assertTrue(newuser.verified_email)
diff --git a/requirements.txt b/requirements.txt
index d502a0496723dcb5d774c3fe87334f5c2ebf2732..4c81764cd7d48b9fefa5c00354c851aacb620f1d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ celery==4.2.0
 celery_once==2.0.0
 certifi==2017.7.27.1
 chardet==3.0.4
+django-cors-headers==2.4.0
 django-enumfields==0.10.0
 djangorestframework==3.7.1
 django-webpack-loader==0.5.0