diff --git a/config/settings/base.py b/config/settings/base.py
index 6a32f0456bfd6f9768e47e51380fd1abcc323f32..74e02545c56d8b048c009cc7b12fd19362b305ae 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -57,6 +57,7 @@ TWIML_BIN = {
     'new': 'EH761b6a35102737e3d21830a484a98a08',
     'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
     'test': 'EH6c0a168b0c6b011047afa1caeb49b241',
+    'verify': 'EHfaea274d4d87f6ff152ac39fea3a87d4',
 }
 
 # Use timezone-aware datetimes internally
@@ -443,6 +444,8 @@ GUARDIAN_RENDER_403 = True
 # See http://django-guardian.readthedocs.io/en/latest/userguide/custom-user-model.html
 GUARDIAN_MONKEY_PATCH = False
 
+# Lifetime of verification codes for contacts
+VERIFICATION_CODE_LIFETIME = timedelta(hours=1)
 
 # Basic auth passwords for LVEM scripted access expire after 365 days.
 PASSWORD_EXPIRATION_TIME = timedelta(days=365)
diff --git a/gracedb/alerts/forms.py b/gracedb/alerts/forms.py
index dbb3f42d29a57aef759ed6662954dfd96f25620f..025be65de6c159de64deb295f9e5beca38e7ee27 100644
--- a/gracedb/alerts/forms.py
+++ b/gracedb/alerts/forms.py
@@ -1,17 +1,23 @@
+from collections import defaultdict
+import logging
+from pyparsing import ParseException
+
 from django import forms
-from django.utils.safestring import mark_safe
+from django.core.exceptions import NON_FIELD_ERRORS
+from django.forms.utils import ErrorList
+from django.utils import timezone
 from django.utils.encoding import force_text
 from django.utils.html import conditional_escape
-from django.forms.utils import ErrorList
-from django.core.exceptions import NON_FIELD_ERRORS
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
 
-from .models import Notification, Contact
+from core.forms import MultipleForm
 from search.query.labels import parseLabelQuery
+from .models import Notification, Contact
+
+# Set up logger
+logger =  logging.getLogger(__name__)
 
-from pyparsing import ParseException
-from collections import defaultdict
-import logging
-log = logging.getLogger(__name__)
 
 def notificationFormFactory(postdata=None, user=None):
     class TF(forms.ModelForm):
@@ -117,58 +123,62 @@ def process_errors(err):
 
     return "\n".join(out_errs)
 
-class ContactForm(forms.ModelForm):
-    # Adjust labels.
-    desc = forms.CharField(label='Description', required=True)
-    call_phone = forms.BooleanField(label='Call', initial=False,
-                                    required=False)
-    text_phone = forms.BooleanField(label='Text', initial=False,
-                                    required=False)
+
+class PhoneContactForm(forms.ModelForm, MultipleForm):
+    key = 'phone'
 
     class Meta:
         model = Contact
-        fields = ['desc','email','phone','call_phone','text_phone']
-        help_texts = {
-            'phone': 'Prototype service: may not be available in the future.'
-        }
-
-    # Custom generator for table format.
-    def as_table(self):
-        row_head = '<tr><th><label for="id_{0}">{1}:</label></th>'
-        row_err = '<td>{2}'
-        row_input = '<input id="id_{0}" name="{0}" type="{3}" /></td></tr>'
-        row_str = row_head + row_err + row_input
-        table_data = {}
-
-        # Build table -----------------------------
-        # Description/email
-        for field in ['desc','email']:
-            table_data[field] = row_str.format(field,self[field].label,
-                process_errors(self[field].errors),"text")
-
-        # Phone number
-        table_data['phone'] = (row_head + row_err +
-            '<input id="id_{0}" name="{0}" type="text" />').format(
-            'phone',self['phone'].label,process_errors(self['phone'].errors))
-        # Add call/text checkboxes.
-        table_data['phone'] += '<br />'
-        table_data['phone'] += ('{1}?<input id="id_{0}" name="{0}"'
-            'type="checkbox" />&nbsp;&nbsp;'.format('call_phone',
-            self['call_phone'].label))
-        table_data['phone'] += ('{1}?<input id="id_{0}" name="{0}"'
-            'type="checkbox" />'.format('text_phone',self['text_phone'].label))
-
-        # Add phone help text.
-        table_data['phone'] += ('<br /><span class="helptext">{0}</span>'
-            '</td></tr>\n'.format(self['phone'].help_text))
-
-        # Compile table_data dict into a list.
-        td = [table_data[k] for k in ['desc','email','phone']]
-
-        # Add non-field errors to beginning.
-        nfe = self.non_field_errors()
-        if nfe:
-            td.insert(0,'<tr><td colspan="2">' \
-                + process_errors(nfe) + '</td></tr>')
-
-        return mark_safe('\n'.join(td))
+        fields = ['description', 'phone', 'phone_method', 'key_field']
+
+    def __init__(self, *args, **kwargs):
+        super(PhoneContactForm, self).__init__(*args, **kwargs)
+        self.fields['phone_method'].required = True
+
+
+class EmailContactForm(forms.ModelForm, MultipleForm):
+    key = 'email'
+
+    class Meta:
+        model = Contact
+        fields = ['description', 'email', 'key_field']
+
+
+class VerifyContactForm(forms.ModelForm):
+    code = forms.CharField(required=True, label='Verification code')
+
+    class Meta:
+        model = Contact
+        fields = ['code']
+
+    def clean(self):
+        data = super(VerifyContactForm, self).clean()
+
+        # Already verified
+        if self.instance.verified:
+            raise forms.ValidationError(_('This contact is already verified.'))
+
+        if (self.instance.verification_code is None):
+            raise forms.ValidationError(_('No verification code has been '
+                'generated. Please request one before attempted to verify '
+                'this contact.'))
+
+        if (timezone.now() > self.instance.verification_expiration):
+            raise forms.ValidationError(_('This verification code has '
+                'expired. Please request a new one.'))
+
+        return data
+
+    def clean_code(self):
+        code = self.cleaned_data['code']
+
+        # Convert to an int
+        try:
+            code = int(code)
+        except:
+            raise forms.ValidationError(_('Incorrect verification code.'))
+
+        if (code != self.instance.verification_code):
+            raise forms.ValidationError(_('Incorrect verification code.'))
+
+        return code
diff --git a/gracedb/alerts/migrations/0002_auto_20190121_1557.py b/gracedb/alerts/migrations/0002_auto_20190121_1557.py
new file mode 100644
index 0000000000000000000000000000000000000000..b0603f3e92554580b47a88e203538cf653bcbd36
--- /dev/null
+++ b/gracedb/alerts/migrations/0002_auto_20190121_1557.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-21 15:57
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='contact',
+            old_name='desc',
+            new_name='description',
+        ),
+        migrations.RemoveField(
+            model_name='contact',
+            name='call_phone',
+        ),
+        migrations.RemoveField(
+            model_name='contact',
+            name='text_phone',
+        ),
+        migrations.AddField(
+            model_name='contact',
+            name='phone_method',
+            field=models.CharField(blank=True, choices=[(b'C', b'Call'), (b'T', b'Text'), (b'B', b'Call and text')], default=None, max_length=1, null=True),
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0003_auto_20190121_1657.py b/gracedb/alerts/migrations/0003_auto_20190121_1657.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7cd635d1dd637bd56314f1ca84341b1d24caa8d
--- /dev/null
+++ b/gracedb/alerts/migrations/0003_auto_20190121_1657.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-21 16:57
+from __future__ import unicode_literals
+
+import alerts.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0002_auto_20190121_1557'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contact',
+            name='email',
+            field=models.EmailField(blank=True, max_length=254, null=True),
+        ),
+        migrations.AlterField(
+            model_name='contact',
+            name='phone',
+            field=alerts.fields.PhoneNumberField(blank=True, max_length=255, null=True),
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0004_contact_verified.py b/gracedb/alerts/migrations/0004_contact_verified.py
new file mode 100644
index 0000000000000000000000000000000000000000..0610c38e2be823ffa904a12dae791d6582c7390f
--- /dev/null
+++ b/gracedb/alerts/migrations/0004_contact_verified.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-21 17:00
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0003_auto_20190121_1657'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contact',
+            name='verified',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0005_auto_20190121_1703.py b/gracedb/alerts/migrations/0005_auto_20190121_1703.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcd398948fd7ae61b0006727eca288510362553e
--- /dev/null
+++ b/gracedb/alerts/migrations/0005_auto_20190121_1703.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-21 17:03
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0004_contact_verified'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='contact',
+            name='verified',
+            field=models.BooleanField(default=False, editable=False),
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0006_auto_20190123_2119.py b/gracedb/alerts/migrations/0006_auto_20190123_2119.py
new file mode 100644
index 0000000000000000000000000000000000000000..e56930778ecfaff2bcb89833f80bec243fc760b1
--- /dev/null
+++ b/gracedb/alerts/migrations/0006_auto_20190123_2119.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-23 21:19
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0005_auto_20190121_1703'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contact',
+            name='verification_code',
+            field=models.IntegerField(editable=False, null=True),
+        ),
+        migrations.AddField(
+            model_name='contact',
+            name='verification_requested',
+            field=models.DateTimeField(editable=False, null=True),
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0007_auto_20190123_2120.py b/gracedb/alerts/migrations/0007_auto_20190123_2120.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d576f920c1bcd0358962eda2914d1f6b32a3e04
--- /dev/null
+++ b/gracedb/alerts/migrations/0007_auto_20190123_2120.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-23 21:20
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0006_auto_20190123_2119'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='contact',
+            old_name='verification_requested',
+            new_name='verification_expires',
+        ),
+    ]
diff --git a/gracedb/alerts/migrations/0008_auto_20190123_2120.py b/gracedb/alerts/migrations/0008_auto_20190123_2120.py
new file mode 100644
index 0000000000000000000000000000000000000000..70dac577b51bd0ed5da2c5a52e52f613b92f01fb
--- /dev/null
+++ b/gracedb/alerts/migrations/0008_auto_20190123_2120.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2019-01-23 21:20
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alerts', '0007_auto_20190123_2120'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='contact',
+            old_name='verification_expires',
+            new_name='verification_expiration',
+        ),
+    ]
diff --git a/gracedb/alerts/models.py b/gracedb/alerts/models.py
index 801f12df993efb948c02eb22b4b12b616ae73b2d..83251c3d55d3e2f476e7fd5946165ffc9587681b 100644
--- a/gracedb/alerts/models.py
+++ b/gracedb/alerts/models.py
@@ -1,29 +1,58 @@
 from collections import defaultdict
+import logging
+import random
+import textwrap
 
+from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.db import models
 from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
+from django.core.mail import EmailMessage
+from django.db import models
+from django.utils import timezone
+from django.utils.http import urlencode
+
+from django_twilio.client import twilio_client
 
+from core.models import CleanSaveModel
 from events.models import Label, Pipeline
 from .fields import PhoneNumberField
+from .phone import get_twilio_from
 
 
+# Set up logger
+logger = logging.getLogger(__name__)
+
 # Set up user model
 UserModel = get_user_model()
 
 
-class Contact(models.Model):
+class Contact(CleanSaveModel):
+    # Phone contact methods
+    CONTACT_PHONE_CALL = 'C'
+    CONTACT_PHONE_TEXT = 'T'
+    CONTACT_PHONE_BOTH = 'B'
+    CONTACT_PHONE_METHODS = (
+        (CONTACT_PHONE_CALL, 'Call'),
+        (CONTACT_PHONE_TEXT, 'Text'),
+        (CONTACT_PHONE_BOTH, 'Call and text'),
+    )
+    # Number of digits in verification codes
+    CODE_DIGITS = 6
+
+    # Fields
     user = models.ForeignKey(UserModel, null=False)
-    desc = models.CharField(max_length=20)
-    email = models.EmailField(blank=True)
-    phone = PhoneNumberField(blank=True, max_length=255)
-    # These fields specify whether alert should be a phone
-    # call or text (or both).
-    call_phone = models.BooleanField(default=False)
-    text_phone = models.BooleanField(default=False)
+    description = models.CharField(max_length=20, blank=False, null=False)
+    email = models.EmailField(blank=True, null=True)
+    phone = PhoneNumberField(blank=True, max_length=255, null=True)
+    phone_method = models.CharField(max_length=1, null=True, blank=True,
+        choices=CONTACT_PHONE_METHODS, default=None)
+    verified = models.BooleanField(default=False, editable=False)
+    verification_code = models.IntegerField(null=True, editable=False)
+    verification_expiration = models.DateTimeField(null=True, editable=False)
+
 
     def __unicode__(self):
-        return u"{0}: {1}".format(self.user.username, self.desc)
+        return u"{0}: {1}".format(self.user.username, self.description)
 
     def clean(self):
         # Mostly used for preventing creation of bad Contact
@@ -32,33 +61,87 @@ class Contact(models.Model):
 
         err_dict = defaultdict(list)
         # If a phone number is given, require either call or text to be True.
-        if self.phone and not (self.call_phone or self.text_phone):
-            err_msg = 'Choose "call" or "text" (or both) for phone alerts.'
-            err_dict['phone'].append(err_msg)
+        if (self.phone is not None and self.phone_method is None):
+            err_msg = 'Choose a phone contact method.'
+            err_dict['phone_method'].append(err_msg)
 
-        if not self.phone and (self.call_phone or self.text_phone):
+        if (self.phone is None and self.phone_method is not None):
             err_msg = '"Call" and "text" should be False for non-phone alerts.'
             err_dict['phone'].append(err_msg)
 
-        # If no e-mail or phone given, raise error.
-        if not (self.email or self.phone):
-            err_msg = 'At least one contact method (email, phone) is required.'
+        # Only one contact method is allowed
+        if (self.email is not None and self.phone is not None):
+            err_msg = \
+                'Only one contact method (email or phone) can be selected.'
             err_dict[NON_FIELD_ERRORS].append(err_msg)
 
+        # If no e-mail or phone given, raise error.
+        # We have to skip this due to really annoying behavior with forms.. :(
+        #if not (self.email or self.phone):
+        #    err_msg = \
+        #        'One contact method (email or phone) is required.'
+        #    err_dict[NON_FIELD_ERRORS].append(err_msg)
+
         if err_dict:
             raise ValidationError(err_dict)
 
-    # Override save method by requiring fully_cleaned objects.
-    def save(self, *args, **kwargs):
-        self.full_clean()
-        super(Contact, self).save()
+    def generate_verification_code(self):
+        self.verification_code = random.randint(10**(self.CODE_DIGITS-1),
+            (10**self.CODE_DIGITS)-1)
+        self.verification_expiration = timezone.now() + \
+            settings.VERIFICATION_CODE_LIFETIME
+        self.save(update_fields=['verification_code',
+            'verification_expiration'])
+
+    def send_verification_code(self):
+        # Message for texts and emails
+        msg = ('Verification code for contact "{desc}" on {host}: {code}. '
+            'If you did not request a verification code or do not know what '
+            'this is, please disregard.').format(desc=self.description,
+            host=settings.LIGO_FQDN, code=self.verification_code)
+
+        if self.email:
+            subject = 'Verification code for contact "{desc}" on {host}' \
+                .format(desc=self.description, host=settings.LIGO_FQDN)
+            email = EmailMessage(subject, msg,
+                from_email=settings.ALERT_EMAIL_FROM, to=[self.email])
+            email.send()
+        elif self.phone:
+            from_ = get_twilio_from()
+
+            if (self.phone_method == self.CONTACT_PHONE_CALL):
+                # If phone method is only call, send a call
+
+                # Convert code to a string with spaces between the numbers
+                # so it's pronounced properly by text-to-voice
+                code = " ".join(str(self.verification_code))
+                urlparams = urlencode({'code': code})
+                twiml_url = '{base}{twiml_bin}?{params}'.format(
+                    base=settings.TWIML_BASE_URL,
+                    twiml_bin=settings.TWIML_BIN['verify'],
+                    params=urlparams)
+                twilio_client.calls.create(to=self.phone, from_=from_,
+                    url=twiml_url, method='GET')
+            else:
+                # If method is text or both, send a text
+                twilio_client.messages.create(to=self.phone, from_=from_,
+                    body=msg)
+
+    def verify(self):
+        self.verified = True
+        self.save(update_fields=['verified'])
 
     def print_info(self):
         """Prints information about Contact object; useful for debugging."""
-        print('Contact "{0}" (user {1}):'.format(self.desc,self.user.username))
-        print('\tE-mail: {0}'.format(self.email))
-        print('\tPhone: {0} (call={1}, text={2})'.format(self.phone,
-                self.call_phone, self.text_phone))
+        info_str = textwrap.dedent("""\
+            Contact "{description}" (user {username})
+            E-mail: {email}
+            Phone: {phone} (method={method})
+            Verified: {verified}
+        """).format(description=self.description, username=self.user.username,
+        email=self.email, phone=self.phone, method=self.phone_method,
+        verified=self.verified)
+        print(info_str)
 
 
 class Notification(models.Model):
@@ -89,5 +172,5 @@ class Notification(models.Model):
             "|".join([a.name for a in self.pipelines.all()]) or "any pipeline",
             label_disp,
             thresh,
-            ", ".join([x.desc for x in self.contacts.all()])
+            ", ".join([x.description for x in self.contacts.all()])
         )
diff --git a/gracedb/alerts/urls.py b/gracedb/alerts/urls.py
index 3a13cdac66da71f5ba550cd8d2faf277b48d0991..a8b4077e50cc89fb2a903eee4cc33d736fe3bdac 100644
--- a/gracedb/alerts/urls.py
+++ b/gracedb/alerts/urls.py
@@ -1,30 +1,38 @@
-
-# Changed for Django 1.11
 from django.conf.urls import url
+
 from . import views
 
+app_name = 'alerts'
+
+
 urlpatterns = [
     # Base /options/ URL
-    url(r'^$', views.index, name="userprofile-home"),
-
-    # /options/contact/
-    url(r'^contact/create$', views.createContact,
-        name="userprofile-create-contact"),
-    url(r'^contact/delete/(?P<id>[\d]+)$', views.deleteContact,
-        name="userprofile-delete-contact"),
-    url(r'^contact/test/(?P<id>[\d]+)$', views.testContact,
-        name="userprofile-test-contact"),
-    #url(r'^contact/edit/(?P<id>[\d]+)$', views.editContact,
-    #    name="userprofile-edit-contact"),
-
-    # /options/notification/
-    url(r'^notification/create$', views.create, name="userprofile-create"),
+    url(r'^$', views.index, name="index"),
+
+    # Contacts
+    url(r'^contact/create/', views.CreateContactView.as_view(),
+        name='create-contact'),
+    url(r'^contact/(?P<pk>\d+)/edit/$', views.EditContactView.as_view(),
+        name="edit-contact"),
+    url(r'^contact/(?P<pk>\d+)/delete/$', views.DeleteContactView.as_view(),
+        name="delete-contact"),
+    url(r'^contact/(?P<pk>\d+)/test/$', views.TestContactView.as_view(),
+        name="test-contact"),
+    url(r'^contact/(?P<pk>\d+)/request-code/$',
+        views.RequestVerificationCodeView.as_view(),
+        name="request-verification-code"),
+    url(r'^contact/(?P<pk>\d+)/verify/$', views.VerifyContactView.as_view(),
+        name="verify-contact"),
+
+    # Notifications
+    url(r'^notification/create/$', views.create, name="create-notification"),
     url(r'^notification/delete/(?P<id>[\d]+)$', views.delete,
-        name="userprofile-delete"),
-    #url(r'^notification/edit/(?P<id>[\d]+)$', views.edit, name="userprofile-edit"),
+        name="delete-notification"),
+    #url(r'^notification/edit/(?P<id>[\d]+)$', views.edit,
+    #    name="edit-notification"),
 
-    # /options/manage_password
-    url(r'^manage_password$', views.managePassword,
-        name="userprofile-manage-password"),
+    # Manage password
+    url(r'^manage_password/$', views.managePassword,
+        name="manage-password"),
 
 ]
diff --git a/gracedb/alerts/validators.py b/gracedb/alerts/validators.py
index 36d17bbfed9c89612bd8ccecded038740a28bb08..267c1ca2dbdaa9b07a22655566614bcb2e89ae08 100644
--- a/gracedb/alerts/validators.py
+++ b/gracedb/alerts/validators.py
@@ -1,5 +1,7 @@
 import phonenumbers
 
+from django.core.exceptions import ValidationError
+
 
 def validate_phone(value):
     # Try to parse phone number
diff --git a/gracedb/alerts/views.py b/gracedb/alerts/views.py
index 88c6dd2e848639e1330f2aaf91098bd3016deb01..4fa2c98a8cbf9ecce2818281d6adf5140436f64f 100644
--- a/gracedb/alerts/views.py
+++ b/gracedb/alerts/views.py
@@ -1,37 +1,46 @@
+import logging
 
-from django.contrib.auth.decorators import login_required
-from django.http import (HttpResponse, HttpResponseRedirect, 
-    HttpResponseNotFound, Http404, HttpResponseForbidden,
-    HttpResponseBadRequest)
 from django.conf import settings
-from django.urls import reverse 
-from django.core.mail import EmailMessage
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
 from django.contrib.auth.models import User
+from django.core.mail import EmailMessage
+from django.db.models import Q
+from django.http import (
+    HttpResponse, HttpResponseRedirect, HttpResponseNotFound,
+    Http404, HttpResponseForbidden, HttpResponseBadRequest
+)
 from django.template import RequestContext
 from django.shortcuts import render
+from django.urls import reverse, reverse_lazy
 from django.utils import timezone
+from django.utils.decorators import method_decorator
+from django.utils.http import urlencode
 from django.utils.safestring import mark_safe
-from django.db.models import Q
-
-from django.contrib import messages
+from django.views.generic.edit import FormView, DeleteView, UpdateView
+from django.views.generic.base import ContextMixin
+from django.views.generic.detail import SingleObjectMixin, DetailView
 
 from django_twilio.client import twilio_client
-import socket
-# Set up logger
-import logging
-log = logging.getLogger(__name__)
 
-from .models import Notification, Contact
-from .forms import ContactForm, notificationFormFactory
-from alerts.phone import get_twilio_from
-from events.permission_utils import internal_user_required, \
-    lvem_user_required, is_external
+from core.views import MultipleFormView
+from events.permission_utils import lvem_user_required, is_external
 from events.models import Label
+from ligoauth.decorators import internal_user_required
 from search.query.labels import labelQuery
+from .forms import (
+    PhoneContactForm, EmailContactForm, VerifyContactForm,
+    notificationFormFactory,
+)
+from .models import Notification, Contact
+from .phone import get_twilio_from
+
+
+# Set up logger
+logger = log = logging.getLogger(__name__)
+
 
 
-# Let's let everybody onto the index view.
-#@internal_user_required
 @login_required
 def index(request):
     notifications = Notification.objects.filter(user=request.user)
@@ -40,6 +49,7 @@ def index(request):
 
     return render(request, 'profile/notifications.html', context=d)
 
+
 @lvem_user_required
 def managePassword(request):
     # lvem_user_required only checks for LVEM group membership,
@@ -76,6 +86,7 @@ def managePassword(request):
 
     return render(request, 'profile/manage_password.html', context=d)
 
+
 @internal_user_required
 def create(request):
     """Create a notification (Notification) via the web interface"""
@@ -129,7 +140,7 @@ def create(request):
                     '{e}.').format(n=t.userlessDisplay(), e=e))
                 t.delete()
 
-            return HttpResponseRedirect(reverse(index))
+            return HttpResponseRedirect(reverse('alerts:index'))
     else:
         form = notificationFormFactory(user=request.user)
     return render(request, 'profile/createNotification.html',
@@ -151,108 +162,208 @@ def delete(request, id):
     messages.info(request,'Notification "{nname}" has been deleted.' \
         .format(nname=t.userlessDisplay()))
     t.delete()
-    return HttpResponseRedirect(reverse(index))
+    return HttpResponseRedirect(reverse('alerts:index'))
 
-#--------------
-#-- Contacts --
-#--------------
 
-@internal_user_required
-def createContact(request):
+###############################################################################
+# Contact views ###############################################################
+###############################################################################
+@method_decorator(internal_user_required, name='dispatch')
+class CreateContactView(MultipleFormView):
+    """Create a contact"""
+    template_name = 'alerts/create_contact.html'
+    success_url = reverse_lazy('alerts:index')
+    form_classes = [PhoneContactForm, EmailContactForm]
 
-    # Handle form.
-    if request.method == "POST":
-        form = ContactForm(request.POST)
-        if form.is_valid():
-            # Create the Contact
-            c = Contact(
-                    user = request.user,
-                    desc = form.cleaned_data['desc'],
-                    email = form.cleaned_data['email'],
-                    phone = form.cleaned_data['phone'],
-                    call_phone = form.cleaned_data['call_phone'],
-                    text_phone = form.cleaned_data['text_phone'],
-                )
-            c.save()
-            messages.info(request, 'Created contact "{cname}".'.format(
-                cname=c.desc))
-            return HttpResponseRedirect(reverse(index))
-    else:
-        form = ContactForm()
-    return render(request, 'profile/createContact.html',
-        context={"form": form})
+    def get_context_data(self, **kwargs):
+        kwargs['idx'] = 0
+        if (self.request.method in ('POST', 'PUT')):
+            form_keys = [f.key for f in self.form_classes]
+            idx = form_keys.index(self.request.POST['key_field'])
+            kwargs['idx'] = idx
+        return kwargs
 
-@internal_user_required
-def testContact(request, id):
-    """Users can test their Contacts through the web interface"""
-    try:
-        c = Contact.objects.get(id=id)
-    except Contact.DoesNotExist:
-        raise Http404
-    if request.user != c.user:
-        return HttpResponseForbidden("Can't test a Contact that isn't yours.")
-    else:
-        messages.info(request, 'Testing contact "{0}".'.format(c.desc))
-        hostname = socket.gethostname()
-        if c.email:
-            # Send test e-mail
-            try:
-                subject = 'Test of contact "{0}" from {1}' \
-                    .format(c.desc, hostname)
-                msg = ('This is a test of contact "{0}" from '
-                    'https://{1}.ligo.org.').format(c.desc, hostname)
-                email = EmailMessage(subject, msg,
-                    from_email=settings.ALERT_EMAIL_FROM, to=[c.email])
-                email.send()
-                log.debug('Sent test e-mail to {0}'.format(c.email))
-            except Exception as e:
-                messages.error(request, ("Error sending test e-mail to {0}: "
-                    "{1}.").format(c.email, e))
-                log.exception('Error sending test e-mail to {0}'.format(c.email))
+    def form_valid(self, form):
+
+        # Remove key_field, add user, and save form
+        if form.cleaned_data.has_key('key_field'):
+            form.cleaned_data.pop('key_field')
+        form.instance.user = self.request.user
+        form.save()
+
+        # Generate message and return
+        messages.info(self.request, 'Created contact "{cname}".'.format(
+            cname=form.instance.description))
+        return super(CreateContactView, self).form_valid(form)
+
+    email_form_valid = phone_form_valid = form_valid
+
+
+@method_decorator(internal_user_required, name='dispatch')
+class EditContactView(UpdateView):
+    """
+    Edit a contact. Users shouldn't be able to edit the actual email address
+    or phone number since that would allow them to circumvent the verification
+    process.
+    """
+    template_name = 'alerts/edit_contact.html'
+    # Have to provide form_class, but it will be dynamically selected below in
+    # get_form()
+    form_class = PhoneContactForm
+    success_url = reverse_lazy('alerts:index')
+
+    def get_form_class(self):
+        if self.object.phone is not None:
+            return PhoneContactForm
+        else:
+            return EmailContactForm
+        return self.form_class
+
+    def get_form(self, form_class=None):
+        form = super(EditContactView, self).get_form(form_class)
+        if isinstance(form, PhoneContactForm):
+            form.fields['phone'].disabled = True
+        elif isinstance(form, EmailContactForm):
+            form.fields['email'].disabled = True
+        return form
+
+    def get_queryset(self):
+        return self.request.user.contact_set.all()
+
+
+@method_decorator(internal_user_required, name='dispatch')
+class DeleteContactView(DeleteView):
+    """Delete a contact"""
+    model = Contact
+    success_url = reverse_lazy('alerts:index')
+
+    def get(self, request, *args, **kwargs):
+        # Override this so that we don't require a confirmation page
+        # for deletion
+        return self.delete(request, *args, **kwargs)
+
+    def delete(self, request, *args, **kwargs):
+        response = super(DeleteContactView, self).delete(request, *args,
+            **kwargs)
+        messages.info(request, 'Contact "{cname}" has been deleted.'.format(
+            cname=self.object.description))
+        return response
+
+    def get_queryset(self):
+        # Queryset should only contain the user's contacts
+        return self.request.user.contact_set.all()
+
+
+@method_decorator(internal_user_required, name='dispatch')
+class TestContactView(DetailView):
+    """Test a contact (must be verified already)"""
+    # Send alerts to all contact methods
+    model = Contact
+    success_url = reverse_lazy('alerts:index')
+
+    def get_queryset(self):
+        return self.request.user.contact_set.all()
+
+    def get(self, request, *args, **kwargs):
+        self.object = self.get_object()
+
+        # Handle case where contact is not verified
+        if not self.object.verified:
+            msg = ('Contact "{desc}" must be verified before it can be '
+                'tested.').format(desc=self.object.description)
+            messages.info(request, msg)
+            return HttpResponseRedirect(self.success_url)
+
+        # Send test notifications
+        msg = 'This is a test of contact "{desc}" from {host}.'.format(
+            desc=self.object.description, host=settings.LIGO_FQDN)
+        if self.object.email:
+            subject = 'Test of contact "{desc}" from {host}'.format(
+                desc=self.object.description, host=settings.LIGO_FQDN)
+            email = EmailMessage(subject, msg, settings.ALERT_EMAIL_FROM,
+                [self.object.email], [])
+            email.send()
+        if self.object.phone:
+            # Get "from" phone number.
+            from_ = get_twilio_from()
+            # Send test call
+            if (self.object.phone_method == Contact.CONTACT_PHONE_CALL or
+                self.object.phone_method == Contact.CONTACT_PHONE_BOTH):
 
-        if c.phone:
-            # Send test phone alert
-            try:
-                # Get "from" phone number.
-                from_ = get_twilio_from()
                 # Construct URL of TwiML bin
-                if c.call_phone:
-                    twiml_url = settings.TWIML_BASE_URL \
-                                + settings.TWIML_BIN['test']
-                    twiml_url += "?server={0}".format(hostname)
-                    # Make call
-                    twilio_client.calls.create(to=c.phone, from_=from_,
-                        url=twiml_url, method='GET')
-                    log.debug('Making test call to {0}'.format(c.phone))
-
-                if c.text_phone:
-                    twilio_client.messages.create(to=c.phone, from_=from_,
-                        body=('This is a test message from https://{0}'
-                              '.ligo.org.').format(hostname))
-                    log.debug('Sending test text to {0}'.format(c.phone))
-            except Exception as e:
-                messages.error(request, "Error contacting {0}: {1}." \
-                    .format(c.phone, e))
-                log.exception('Error contacting {0}: {1}'.format(c.phone, e))
+                twiml_url = '{base}{twiml_bin}'.format(
+                    base=settings.TWIML_BASE_URL,
+                    twiml_bin=settings.TWIML_BIN['test'])
 
-        return HttpResponseRedirect(reverse(index))
+                # Make call
+                twilio_client.calls.create(to=self.object.phone, from_=from_,
+                    url=twiml_url, method='GET')
 
-@internal_user_required
-def editContact(request, id):
-    raise Http404
+            if (self.object.phone_method == Contact.CONTACT_PHONE_TEXT or
+                self.object.phone_method == Contact.CONTACT_PHONE_BOTH):
+        
+                twilio_client.messages.create(to=self.object.phone,
+                    from_=from_, body=msg)
 
-@internal_user_required
-def deleteContact(request, id):
-    """Users can delete their Contacts through the web interface"""
-    try:
-        c = Contact.objects.get(id=id)
-    except Contact.DoesNotExist:
-        raise Http404
-    if request.user != c.user:
-        return HttpResponseForbidden(("You are not authorized to modify "
-            "another user's Contacts."))
-    messages.info(request, 'Contact "{cname}" has been deleted.' \
-        .format(cname=c.desc))
-    c.delete()
-    return HttpResponseRedirect(reverse(index))
+        # Message for web view
+        messages.info(request, 'Testing contact "{desc}".'.format(
+            desc=self.object.description))
+
+        return HttpResponseRedirect(self.success_url)
+
+
+@method_decorator(internal_user_required, name='dispatch')
+class VerifyContactView(UpdateView):
+    """Request a verification code or verify a contact"""
+    template_name = 'alerts/verify_contact.html'
+    form_class = VerifyContactForm
+    success_url = reverse_lazy('alerts:index')
+
+    def get_queryset(self):
+        return self.request.user.contact_set.all()
+
+    def form_valid(self, form):
+        self.object.verify()
+        msg = 'Contact "{cname}" successfully verified.'.format(
+            cname=self.object.description)
+        messages.info(self.request, msg)
+        return super(VerifyContactView, self).form_valid(form)
+
+    def get_context_data(self, **kwargs):
+        context = super(VerifyContactView, self).get_context_data(**kwargs)
+
+        # Determine if verification code exists and is expired
+        if (self.object.verification_code is not None and
+            timezone.now() > self.object.verification_expiration):
+            context['code_expired'] = True
+
+        return context
+
+
+@method_decorator(internal_user_required, name='dispatch')
+class RequestVerificationCodeView(DetailView):
+    """Redirect view for requesting a contact verification code"""
+    model = Contact
+
+    def get_queryset(self):
+        return self.request.user.contact_set.all()
+
+    def get(self, request, *args, **kwargs):
+        self.object = self.get_object()
+
+        # Handle case where contact is already verified
+        if self.object.verified:
+            msg = 'Contact "{desc}" is already verified.'.format(
+                desc=self.object.description)
+            messages.info(request, msg)
+            return HttpResponseRedirect(reverse('alerts:index'))
+
+        # Otherwise, set up verification code for contact
+        self.object.generate_verification_code()
+
+        # Send verification code
+        self.object.send_verification_code()
 
+        messages.info(request, "Verification code sent.")
+        return HttpResponseRedirect(reverse('alerts:verify-contact',
+            args=[self.object.pk]))
diff --git a/gracedb/static/css/bootstrap_buttons.css b/gracedb/static/css/bootstrap_buttons.css
new file mode 100644
index 0000000000000000000000000000000000000000..1f34909ee892c299b0d55f42badbd75205f971ec
--- /dev/null
+++ b/gracedb/static/css/bootstrap_buttons.css
@@ -0,0 +1,671 @@
+/*!
+ * Bootstrap v4.2.1 (https://getbootstrap.com/)
+ * Copyright 2011-2018 The Bootstrap Authors
+ * Copyright 2011-2018 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ */
+button {
+  border-radius: 0;
+}
+
+button:focus {
+  outline: 1px dotted;
+  outline: 5px auto -webkit-focus-ring-color;
+}
+
+button {
+  margin: 0;
+  font-family: inherit;
+  font-size: inherit;
+  line-height: inherit;
+}
+
+button  {
+  overflow: visible;
+}
+
+button {
+  text-transform: none;
+}
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+  -webkit-appearance: button;
+}
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+  padding: 0;
+  border-style: none;
+}
+
+
+.btn {
+  display: inline-block;
+  font-weight: 400;
+  color: #212529;
+  text-align: center;
+  vertical-align: middle;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  background-color: transparent;
+  border: 1px solid transparent;
+  padding: 0.375rem 0.75rem;
+  font-size: 1.4rem;
+  line-height: 1.5;
+  border-radius: 0.3rem;
+  transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+  text-decoration: none;
+}
+
+@media screen and (prefers-reduced-motion: reduce) {
+  .btn {
+    transition: none;
+  }
+}
+
+.btn:hover {
+  color: #212529;
+  text-decoration: none;
+}
+
+.btn:focus, .btn.focus {
+  outline: 0;
+  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
+}
+
+.btn.disabled, .btn:disabled {
+  opacity: 0.65;
+}
+
+.btn:not(:disabled):not(.disabled) {
+  cursor: pointer;
+}
+
+a.btn.disabled,
+fieldset:disabled a.btn {
+  pointer-events: none;
+}
+
+.btn-primary {
+  color: #fff;
+  background-color: #007bff;
+  border-color: #007bff;
+}
+
+.btn-primary:hover {
+  color: #fff;
+  background-color: #0069d9;
+  border-color: #0062cc;
+}
+
+.btn-primary:focus, .btn-primary.focus {
+  box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);
+}
+
+.btn-primary.disabled, .btn-primary:disabled {
+  color: #fff;
+  background-color: #007bff;
+  border-color: #007bff;
+}
+
+.btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active,
+.show > .btn-primary.dropdown-toggle {
+  color: #fff;
+  background-color: #0062cc;
+  border-color: #005cbf;
+}
+
+.btn-primary:not(:disabled):not(.disabled):active:focus, .btn-primary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-primary.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(38, 143, 255, 0.5);
+}
+
+.btn-secondary {
+  color: #fff;
+  background-color: #6c757d;
+  border-color: #6c757d;
+}
+
+.btn-secondary:hover {
+  color: #fff;
+  background-color: #5a6268;
+  border-color: #545b62;
+}
+
+.btn-secondary:focus, .btn-secondary.focus {
+  box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);
+}
+
+.btn-secondary.disabled, .btn-secondary:disabled {
+  color: #fff;
+  background-color: #6c757d;
+  border-color: #6c757d;
+}
+
+.btn-secondary:not(:disabled):not(.disabled):active, .btn-secondary:not(:disabled):not(.disabled).active,
+.show > .btn-secondary.dropdown-toggle {
+  color: #fff;
+  background-color: #545b62;
+  border-color: #4e555b;
+}
+
+.btn-secondary:not(:disabled):not(.disabled):active:focus, .btn-secondary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-secondary.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5);
+}
+
+.btn-success {
+  color: #fff;
+  background-color: #28a745;
+  border-color: #28a745;
+}
+
+.btn-success:hover {
+  color: #fff;
+  background-color: #218838;
+  border-color: #1e7e34;
+}
+
+.btn-success:focus, .btn-success.focus {
+  box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);
+}
+
+.btn-success.disabled, .btn-success:disabled {
+  color: #fff;
+  background-color: #28a745;
+  border-color: #28a745;
+}
+
+.btn-success:not(:disabled):not(.disabled):active, .btn-success:not(:disabled):not(.disabled).active,
+.show > .btn-success.dropdown-toggle {
+  color: #fff;
+  background-color: #1e7e34;
+  border-color: #1c7430;
+}
+
+.btn-success:not(:disabled):not(.disabled):active:focus, .btn-success:not(:disabled):not(.disabled).active:focus,
+.show > .btn-success.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(72, 180, 97, 0.5);
+}
+
+.btn-info {
+  color: #fff;
+  background-color: #17a2b8;
+  border-color: #17a2b8;
+}
+
+.btn-info:hover {
+  color: #fff;
+  background-color: #138496;
+  border-color: #117a8b;
+}
+
+.btn-info:focus, .btn-info.focus {
+  box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);
+}
+
+.btn-info.disabled, .btn-info:disabled {
+  color: #fff;
+  background-color: #17a2b8;
+  border-color: #17a2b8;
+}
+
+.btn-info:not(:disabled):not(.disabled):active, .btn-info:not(:disabled):not(.disabled).active,
+.show > .btn-info.dropdown-toggle {
+  color: #fff;
+  background-color: #117a8b;
+  border-color: #10707f;
+}
+
+.btn-info:not(:disabled):not(.disabled):active:focus, .btn-info:not(:disabled):not(.disabled).active:focus,
+.show > .btn-info.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(58, 176, 195, 0.5);
+}
+
+.btn-warning {
+  color: #212529;
+  background-color: #ffc107;
+  border-color: #ffc107;
+}
+
+.btn-warning:hover {
+  color: #212529;
+  background-color: #e0a800;
+  border-color: #d39e00;
+}
+
+.btn-warning:focus, .btn-warning.focus {
+  box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);
+}
+
+.btn-warning.disabled, .btn-warning:disabled {
+  color: #212529;
+  background-color: #ffc107;
+  border-color: #ffc107;
+}
+
+.btn-warning:not(:disabled):not(.disabled):active, .btn-warning:not(:disabled):not(.disabled).active,
+.show > .btn-warning.dropdown-toggle {
+  color: #212529;
+  background-color: #d39e00;
+  border-color: #c69500;
+}
+
+.btn-warning:not(:disabled):not(.disabled):active:focus, .btn-warning:not(:disabled):not(.disabled).active:focus,
+.show > .btn-warning.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(222, 170, 12, 0.5);
+}
+
+.btn-danger {
+  color: #fff;
+  background-color: #dc3545;
+  border-color: #dc3545;
+}
+
+.btn-danger:hover {
+  color: #fff;
+  background-color: #c82333;
+  border-color: #bd2130;
+}
+
+.btn-danger:focus, .btn-danger.focus {
+  box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);
+}
+
+.btn-danger.disabled, .btn-danger:disabled {
+  color: #fff;
+  background-color: #dc3545;
+  border-color: #dc3545;
+}
+
+.btn-danger:not(:disabled):not(.disabled):active, .btn-danger:not(:disabled):not(.disabled).active,
+.show > .btn-danger.dropdown-toggle {
+  color: #fff;
+  background-color: #bd2130;
+  border-color: #b21f2d;
+}
+
+.btn-danger:not(:disabled):not(.disabled):active:focus, .btn-danger:not(:disabled):not(.disabled).active:focus,
+.show > .btn-danger.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(225, 83, 97, 0.5);
+}
+
+.btn-light {
+  color: #212529;
+  background-color: #f8f9fa;
+  border-color: #f8f9fa;
+}
+
+.btn-light:hover {
+  color: #212529;
+  background-color: #e2e6ea;
+  border-color: #dae0e5;
+}
+
+.btn-light:focus, .btn-light.focus {
+  box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);
+}
+
+.btn-light.disabled, .btn-light:disabled {
+  color: #212529;
+  background-color: #f8f9fa;
+  border-color: #f8f9fa;
+}
+
+.btn-light:not(:disabled):not(.disabled):active, .btn-light:not(:disabled):not(.disabled).active,
+.show > .btn-light.dropdown-toggle {
+  color: #212529;
+  background-color: #dae0e5;
+  border-color: #d3d9df;
+}
+
+.btn-light:not(:disabled):not(.disabled):active:focus, .btn-light:not(:disabled):not(.disabled).active:focus,
+.show > .btn-light.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5);
+}
+
+.btn-dark {
+  color: #fff;
+  background-color: #343a40;
+  border-color: #343a40;
+}
+
+.btn-dark:hover {
+  color: #fff;
+  background-color: #23272b;
+  border-color: #1d2124;
+}
+
+.btn-dark:focus, .btn-dark.focus {
+  box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);
+}
+
+.btn-dark.disabled, .btn-dark:disabled {
+  color: #fff;
+  background-color: #343a40;
+  border-color: #343a40;
+}
+
+.btn-dark:not(:disabled):not(.disabled):active, .btn-dark:not(:disabled):not(.disabled).active,
+.show > .btn-dark.dropdown-toggle {
+  color: #fff;
+  background-color: #1d2124;
+  border-color: #171a1d;
+}
+
+.btn-dark:not(:disabled):not(.disabled):active:focus, .btn-dark:not(:disabled):not(.disabled).active:focus,
+.show > .btn-dark.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5);
+}
+
+.btn-outline-primary {
+  color: #007bff;
+  border-color: #007bff;
+}
+
+.btn-outline-primary:hover {
+  color: #fff;
+  background-color: #007bff;
+  border-color: #007bff;
+}
+
+.btn-outline-primary:focus, .btn-outline-primary.focus {
+  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
+}
+
+.btn-outline-primary.disabled, .btn-outline-primary:disabled {
+  color: #007bff;
+  background-color: transparent;
+}
+
+.btn-outline-primary:not(:disabled):not(.disabled):active, .btn-outline-primary:not(:disabled):not(.disabled).active,
+.show > .btn-outline-primary.dropdown-toggle {
+  color: #fff;
+  background-color: #007bff;
+  border-color: #007bff;
+}
+
+.btn-outline-primary:not(:disabled):not(.disabled):active:focus, .btn-outline-primary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-primary.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.5);
+}
+
+.btn-outline-secondary {
+  color: #6c757d;
+  border-color: #6c757d;
+}
+
+.btn-outline-secondary:hover {
+  color: #fff;
+  background-color: #6c757d;
+  border-color: #6c757d;
+}
+
+.btn-outline-secondary:focus, .btn-outline-secondary.focus {
+  box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);
+}
+
+.btn-outline-secondary.disabled, .btn-outline-secondary:disabled {
+  color: #6c757d;
+  background-color: transparent;
+}
+
+.btn-outline-secondary:not(:disabled):not(.disabled):active, .btn-outline-secondary:not(:disabled):not(.disabled).active,
+.show > .btn-outline-secondary.dropdown-toggle {
+  color: #fff;
+  background-color: #6c757d;
+  border-color: #6c757d;
+}
+
+.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, .btn-outline-secondary:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-secondary.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5);
+}
+
+.btn-outline-success {
+  color: #28a745;
+  border-color: #28a745;
+}
+
+.btn-outline-success:hover {
+  color: #fff;
+  background-color: #28a745;
+  border-color: #28a745;
+}
+
+.btn-outline-success:focus, .btn-outline-success.focus {
+  box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);
+}
+
+.btn-outline-success.disabled, .btn-outline-success:disabled {
+  color: #28a745;
+  background-color: transparent;
+}
+
+.btn-outline-success:not(:disabled):not(.disabled):active, .btn-outline-success:not(:disabled):not(.disabled).active,
+.show > .btn-outline-success.dropdown-toggle {
+  color: #fff;
+  background-color: #28a745;
+  border-color: #28a745;
+}
+
+.btn-outline-success:not(:disabled):not(.disabled):active:focus, .btn-outline-success:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-success.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.5);
+}
+
+.btn-outline-info {
+  color: #17a2b8;
+  border-color: #17a2b8;
+}
+
+.btn-outline-info:hover {
+  color: #fff;
+  background-color: #17a2b8;
+  border-color: #17a2b8;
+}
+
+.btn-outline-info:focus, .btn-outline-info.focus {
+  box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);
+}
+
+.btn-outline-info.disabled, .btn-outline-info:disabled {
+  color: #17a2b8;
+  background-color: transparent;
+}
+
+.btn-outline-info:not(:disabled):not(.disabled):active, .btn-outline-info:not(:disabled):not(.disabled).active,
+.show > .btn-outline-info.dropdown-toggle {
+  color: #fff;
+  background-color: #17a2b8;
+  border-color: #17a2b8;
+}
+
+.btn-outline-info:not(:disabled):not(.disabled):active:focus, .btn-outline-info:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-info.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(23, 162, 184, 0.5);
+}
+
+.btn-outline-warning {
+  color: #ffc107;
+  border-color: #ffc107;
+}
+
+.btn-outline-warning:hover {
+  color: #212529;
+  background-color: #ffc107;
+  border-color: #ffc107;
+}
+
+.btn-outline-warning:focus, .btn-outline-warning.focus {
+  box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);
+}
+
+.btn-outline-warning.disabled, .btn-outline-warning:disabled {
+  color: #ffc107;
+  background-color: transparent;
+}
+
+.btn-outline-warning:not(:disabled):not(.disabled):active, .btn-outline-warning:not(:disabled):not(.disabled).active,
+.show > .btn-outline-warning.dropdown-toggle {
+  color: #212529;
+  background-color: #ffc107;
+  border-color: #ffc107;
+}
+
+.btn-outline-warning:not(:disabled):not(.disabled):active:focus, .btn-outline-warning:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-warning.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.5);
+}
+
+.btn-outline-danger {
+  color: #dc3545;
+  border-color: #dc3545;
+}
+
+.btn-outline-danger:hover {
+  color: #fff;
+  background-color: #dc3545;
+  border-color: #dc3545;
+}
+
+.btn-outline-danger:focus, .btn-outline-danger.focus {
+  box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);
+}
+
+.btn-outline-danger.disabled, .btn-outline-danger:disabled {
+  color: #dc3545;
+  background-color: transparent;
+}
+
+.btn-outline-danger:not(:disabled):not(.disabled):active, .btn-outline-danger:not(:disabled):not(.disabled).active,
+.show > .btn-outline-danger.dropdown-toggle {
+  color: #fff;
+  background-color: #dc3545;
+  border-color: #dc3545;
+}
+
+.btn-outline-danger:not(:disabled):not(.disabled):active:focus, .btn-outline-danger:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-danger.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.5);
+}
+
+.btn-outline-light {
+  color: #f8f9fa;
+  border-color: #f8f9fa;
+}
+
+.btn-outline-light:hover {
+  color: #212529;
+  background-color: #f8f9fa;
+  border-color: #f8f9fa;
+}
+
+.btn-outline-light:focus, .btn-outline-light.focus {
+  box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);
+}
+
+.btn-outline-light.disabled, .btn-outline-light:disabled {
+  color: #f8f9fa;
+  background-color: transparent;
+}
+
+.btn-outline-light:not(:disabled):not(.disabled):active, .btn-outline-light:not(:disabled):not(.disabled).active,
+.show > .btn-outline-light.dropdown-toggle {
+  color: #212529;
+  background-color: #f8f9fa;
+  border-color: #f8f9fa;
+}
+
+.btn-outline-light:not(:disabled):not(.disabled):active:focus, .btn-outline-light:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-light.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5);
+}
+
+.btn-outline-dark {
+  color: #343a40;
+  border-color: #343a40;
+}
+
+.btn-outline-dark:hover {
+  color: #fff;
+  background-color: #343a40;
+  border-color: #343a40;
+}
+
+.btn-outline-dark:focus, .btn-outline-dark.focus {
+  box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);
+}
+
+.btn-outline-dark.disabled, .btn-outline-dark:disabled {
+  color: #343a40;
+  background-color: transparent;
+}
+
+.btn-outline-dark:not(:disabled):not(.disabled):active, .btn-outline-dark:not(:disabled):not(.disabled).active,
+.show > .btn-outline-dark.dropdown-toggle {
+  color: #fff;
+  background-color: #343a40;
+  border-color: #343a40;
+}
+
+.btn-outline-dark:not(:disabled):not(.disabled):active:focus, .btn-outline-dark:not(:disabled):not(.disabled).active:focus,
+.show > .btn-outline-dark.dropdown-toggle:focus {
+  box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5);
+}
+
+.btn-link {
+  font-weight: 400;
+  color: #007bff;
+}
+
+.btn-link:hover {
+  color: #0056b3;
+  text-decoration: underline;
+}
+
+.btn-link:focus, .btn-link.focus {
+  text-decoration: underline;
+  box-shadow: none;
+}
+
+.btn-link:disabled, .btn-link.disabled {
+  color: #6c757d;
+  pointer-events: none;
+}
+
+.btn-lg, .btn-group-lg > .btn {
+  padding: 0.5rem 1rem;
+  font-size: 1.25rem;
+  line-height: 1.5;
+  border-radius: 0.3rem;
+}
+
+.btn-sm, .btn-group-sm > .btn {
+  padding: 0.25rem 0.5rem;
+  font-size: 0.875rem;
+  line-height: 1.5;
+  border-radius: 0.2rem;
+}
+
+.btn-block {
+  display: block;
+  width: 100%;
+}
+
+.btn-block + .btn-block {
+  margin-top: 0.5rem;
+}
+
+input[type="submit"].btn-block,
+input[type="reset"].btn-block,
+input[type="button"].btn-block {
+  width: 100%;
+}
diff --git a/gracedb/templates/alerts/create_contact.html b/gracedb/templates/alerts/create_contact.html
new file mode 100644
index 0000000000000000000000000000000000000000..f8887d32a98bf04687ab1e29c8d800942570ed89
--- /dev/null
+++ b/gracedb/templates/alerts/create_contact.html
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block headcontents %}
+    {{ block.super }}
+    <link rel="stylesheet" href="{% static "jquery-ui/themes/base/jquery-ui.min.css" %}" />
+{% endblock %}
+
+{% block jscript %}
+<script src="{% static "jquery/dist/jquery.min.js" %}"></script>
+<script src="{% static "jquery-ui/jquery-ui.min.js" %}"></script>
+<script type="text/javascript">
+$(document).ready(function() {
+    $("#tabs").toggle();
+    $("#tabs").tabs({active: {{ idx }}});
+});
+</script>
+{% endblock %}
+
+{% block title %}Options | Create Contact{% endblock %}
+{% block heading %}Create Contact{% endblock %}
+{% block pageid %}userprofile{% endblock %}
+
+
+
+{% block content %}
+
+<br />
+<div style="padding: 10px;">
+<h3>Instructions</h3>
+<ul>
+    <li>Choose an email or phone alert.</li>
+    <li>A description is required.</li>
+    <li>For phone alerts, choose call, text, or both.</li>
+    <li><b>IMPORTANT: after your contact is created, it must be verified before you can receive alerts. You can verify contacts on the previous page where a list of your available contacts is shown.</b></li>
+</ul>
+</div>
+
+<div id="tabs" style="min-width: 300px; max-width: 400px; display: none;">
+  <ul>
+    {% for form in forms %}
+    <li><a href="#tab-{{ forloop.counter }}">{{ form.key|title }} alert</a></li>
+    {% endfor %}
+  </ul>
+  {% for form in forms %}
+  <div id="tab-{{ forloop.counter }}">
+    <form method="POST" class="contactform">
+      <table>
+        {{ form.as_table }}
+      </table>
+      <input type="submit" value="Submit" />
+    </form>
+  </div>
+  {% endfor %}
+</div>
+
+
+{% endblock %}
diff --git a/gracedb/templates/alerts/edit_contact.html b/gracedb/templates/alerts/edit_contact.html
new file mode 100644
index 0000000000000000000000000000000000000000..f71c712b27dc585af8232d4514ac9516f617a2f1
--- /dev/null
+++ b/gracedb/templates/alerts/edit_contact.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+
+{% block title %}Options | Edit Contact{% endblock %}
+{% block heading %}Edit contact &quot;{{ object.description }}&quot;{% endblock %}
+
+{% block content %}
+<br />
+
+<form method="POST">
+    <table>
+        {{ form.as_table }}
+    </table>
+    <input type="submit" value="Submit" />
+</form>
+
+{% endblock %}
diff --git a/gracedb/templates/alerts/verify_contact.html b/gracedb/templates/alerts/verify_contact.html
new file mode 100644
index 0000000000000000000000000000000000000000..b9b238518ed1f41de1bf54af99bbcc88927541a2
--- /dev/null
+++ b/gracedb/templates/alerts/verify_contact.html
@@ -0,0 +1,57 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block headcontents %}
+    <link rel="stylesheet" href="{% static "css/bootstrap_buttons.css" %}"></script>
+    {{ block.super }}
+{% endblock %}
+
+{% block title %}Verify contact{% endblock %}
+{% block heading %}
+Verify contact &quot;{{ contact.description }}&quot;
+{% endblock %}
+
+
+{% block content %}
+
+<br />
+{% if messages %}
+<div class="flash">
+    {% for m in messages %}
+        <p>{{ m }}</p>
+    {% endfor %}
+</div>
+{% endif %}
+
+{% if object.verified %}
+This contact is already verified.
+{% else %}
+    <h3>A verification code will be sent via:</h3>
+    <ul>
+        <li>Email for email contacts</li>
+        <li>Text message for phone contacts who have specified &quot;text&quot; or &quot;call and text&quot;</li>
+        <li>Voice call for phone contacts who have specified &quot;call&quot;</li>
+    </ul>
+    <a class="btn btn-primary" href="{% url "alerts:request-verification-code" object.pk %}">
+    {% if contact.verification_code %}
+        Request a new verification code
+    {% else %}
+        Request a verification code
+    {% endif %}
+    </a>
+    {% if contact.verification_code %}
+    <br />
+    <br />
+        {% if code_expired %}
+            Your verification code has expired.  Use the button above to request a new one.
+        {% else %}
+            Your current verification code expires in <b>{{ contact.verification_expiration|timeuntil }}</b>.
+            <form method="POST">
+                <table>{{ form.as_table }}</table>
+                <input type="submit" value="Submit" />
+            </form>
+        {% endif %}
+    {% endif %}
+{% endif %}
+
+{% endblock %}
diff --git a/gracedb/templates/navbar_frag.html b/gracedb/templates/navbar_frag.html
index 918dd140a9489a701b9f311a7a2894ee95057a1b..82c48b9a102f78bc699159db873258e9d60ec344 100644
--- a/gracedb/templates/navbar_frag.html
+++ b/gracedb/templates/navbar_frag.html
@@ -9,7 +9,7 @@
     {% endif %}
     <li id="nav-latest"><a href="{% url "latest" %}">Latest</a></li>
     {% if user.is_authenticated %}
-    <li id="nav-userprofile"><a href="{% url "userprofile-home" %}">Options</a></li>
+    <li id="nav-userprofile"><a href="{% url "alerts:index" %}">Options</a></li>
     {% endif %}
     <li id="nav-docs"><a href="{% url "home" %}documentation/">Documentation</a></li>
     {% if user %}
diff --git a/gracedb/templates/profile/createContact.html b/gracedb/templates/profile/createContact.html
deleted file mode 100644
index 7e47febfbd076e952333be902a14b020e4f0ceb4..0000000000000000000000000000000000000000
--- a/gracedb/templates/profile/createContact.html
+++ /dev/null
@@ -1,26 +0,0 @@
-{% extends "base.html" %}
-
-{% block title %}Options | Create Contact{% endblock %}
-{% block heading %}Create Contact{% endblock %}
-{% block pageid %}userprofile{% endblock %}
-
-{% block content %}
-
-<br />
-<div style="padding: 10px;">
-<h3>Instructions</h3>
-<ul>
-    <li>A description of your contact is required.</li>
-    <li>Choose a contact method (e-mail, phone, or both).</li>
-    <li>For phone alerts, choose call, text, or both.</li>
-</ul>
-</div>
-
-<form method="POST">
-    <table>
-        {{ form.as_table }}
-    </table>
-    <input type="submit" value="Submit" />
-</form>
-
-{% endblock %}
diff --git a/gracedb/templates/profile/notifications.html b/gracedb/templates/profile/notifications.html
index 09ae79d757c2cbf3f4d21bd7e9c5e11916a42cb6..cd394e2295fb79a1683992c29cf72e023388e21a 100644
--- a/gracedb/templates/profile/notifications.html
+++ b/gracedb/templates/profile/notifications.html
@@ -4,6 +4,12 @@
 {% block heading %}<!-- leave heading blank -->{% endblock %}
 {% block pageid %}userprofile{% endblock %}
 
+{% load static %}
+{% block headcontents %}
+    <link rel="stylesheet" href="{% static "css/bootstrap_buttons.css" %}"></script>
+    {{ block.super }}
+{% endblock %}
+
 {% block content %}
 
 {% if messages %}
@@ -14,55 +20,74 @@
 </div>
 {% endif %}
 
-<h2>Contacts (LVC Users)</h2>
+{# Show contacts and notification to internal users only#}
+{% if user_is_internal %}
+<h2>Contacts</h2>
+{% if contacts %}
+<ul style="list-style-type: none; padding-left: 10px;">
 {% for contact in contacts %}
-    <ul>
-        <li>
-            <a href="{% url "userprofile-test-contact" contact.id %}">Test</a>
-            <a href="{% url "userprofile-delete-contact" contact.id %}">Delete</a>
-            {% comment %}
-            This next part is gross but necessary due to Django whitespace messiness.
-            {% endcomment %}
-            {{contact.desc}} |
-            {% if contact.email and contact.call_phone and contact.text_phone %}
-                Email {{contact.email}}, call/text {{contact.phone}}
-            {% elif contact.email and contact.call_phone %}
-                Email {{contact.email}}, call {{contact.phone}}
-            {% elif contact.email and contact.text_phone %}
-                Email {{contact.email}}, text {{contact.phone}}
-            {% elif contact.email %}
-                Email {{contact.email}}
-            {% elif contact.call_phone and contact.text_phone %}
+    <li style="padding-bottom: 5px;">
+        {% if contact.verified %}
+            <a class="btn btn-primary" href="{% url "alerts:test-contact" contact.id %}">Test</a>
+        {% else %}
+            <button class="btn btn-primary" disabled>Test</button>
+        {% endif %}
+        <a class="btn btn-secondary" href="{% url "alerts:edit-contact" contact.id %}">Edit</a>
+        <a class="btn btn-danger" href="{% url "alerts:delete-contact" contact.id %}">Delete</a>
+        <b>{{ contact.description }}</b> |
+        {% if contact.email %}
+            Email {{ contact.email }}
+        {% elif contact.phone %}
+            {% if contact.phone_method == 'B' %}
                 Call/text {{contact.phone}}
-            {% elif contact.call_phone %}
-                Call {{contact.phone}}
-            {% elif contact.text_phone %}
+            {% elif contact.phone_method == 'T' %}
                 Text {{contact.phone}}
-            {% else %}
-                ERROR: this contact has no contact method.
+            {% elif contact.phone_method == 'C' %}
+                Call {{contact.phone}}
             {% endif %}
-        </li>
-    </ul>
+        {% else %}
+            <b>ERROR: no contact method found.</b>
+        {% endif %}
+        <b>
+        {% if contact.verified %}
+        <div style="color: green; display: inline-block;">Verified</div>
+        {% else %}
+        <div style="color: red; display: inline-block;">Not verified</div>
+        <a class="btn btn-warning" href="{% url "alerts:verify-contact" contact.id %}">Verify</a>
+        {% endif %}
+        </b>
+    </li>
 {% endfor %}
+</ul>
+{% endif %}
 
-<a href="{% url "userprofile-create-contact" %}">Create New Contact</a>
+<a href="{% url "alerts:create-contact" %}">Create New Contact</a>
 <br/><br/>
 
-<h2>Notifications (LVC Users)</h2>
+<h2>Notifications</h2>
+{% if notifications %}
+<ul>
 {% for notification in notifications %}
-   <ul>
-     <li> 
-        <a href="{% url "userprofile-delete" notification.id %}">Delete</a>
+    <li> 
+        <a href="{% url "alerts:delete-notification" notification.id %}">Delete</a>
         {{ notification.userlessDisplay }}
-     </li>
-   </ul>
+    </li>
 {% endfor %}
+</ul>
+{% endif %}
 
-<a href="{% url "userprofile-create" %}">Create New Notification (LVC users)</a>
+<a href="{% url "alerts:create-notification" %}">Create New Notification</a>
 <br/><br/>
+{% endif %}
 
-<h2>Passwords for Scripted Access (LV-EM users)</h2>
-
-<a href="{% url "userprofile-manage-password" %}">Manage Password</a>
+{# Show password page only to LV-EM users who are not also internal #}
+{# But let the admins (superusers) see it with some additions #}
+{% if user_is_lvem and not user_is_internal or user.is_superuser %}
+    {% if user.is_superuser %}
+    <h2 style="border: 2px solid red; color: red; display: inline-block;"><b>The following section is only visible to non-internal LV-EM members. You can see it (and this message) because you are a superuser.</b></h2>
+    {% endif %}
+<h2>Passwords for Scripted Access</h2>
+<a href="{% url "alerts:manage-password" %}">Manage Password</a>
+{% endif %}
 
 {% endblock %}