diff --git a/Dockerfile b/Dockerfile
index 4546c5bc1711a2fa2df489886bf23750afc47930..e56611a38d04649f8fa365197863596b63b487f6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -137,5 +137,11 @@ RUN chmod 0755 /usr/local/bin/entrypoint && \
     find /app/gracedb_project -type d -exec chmod 0755 {} + && \
     find /app/gracedb_project -type f -exec chmod 0644 {} +
 
+# create and set scitoken key cache directory
+RUN mkdir /app/scitokens_cache && \
+    chown gracedb:www-data /app/scitokens_cache && \
+    chmod 0750 /app/scitokens_cache
+ENV XDG_CACHE_HOME /app/scitokens_cache
+
 ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
 CMD ["/usr/local/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
diff --git a/config/settings/base.py b/config/settings/base.py
index 02e3600348678a97b9135975eeacf81110dccd0f..3290989b7236215591d8ce81a3c3234e0b4997bb 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -319,6 +319,11 @@ X509_INFOS_HEADER = 'HTTP_X_FORWARDED_TLS_CLIENT_CERT_INFOS'
 # Path to CA store for X509 certificate verification
 CAPATH = '/etc/grid-security/certificates'
 
+# SciTokens claims settings
+SCITOKEN_ISSUER = "https://cilogon.org/ligo"
+SCITOKEN_AUDIENCE = ["ANY"]
+SCITOKEN_SCOPE = "read:/GraceDB"
+
 # List of authentication backends to use when attempting to authenticate
 # a user.  Will be used in this order.  Authentication for the API is
 # handled by the REST_FRAMEWORK dictionary.
@@ -414,6 +419,7 @@ REST_FRAMEWORK = {
     },
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'api.backends.GraceDbAuthenticatedAuthentication',
+        'api.backends.GraceDbSciTokenAuthentication',
         'api.backends.GraceDbX509Authentication',
         'api.backends.GraceDbBasicAuthentication',
     ),
diff --git a/config/settings/container/base.py b/config/settings/container/base.py
index e86da12dd881bfcebaaa677a60a46c048d597395..7c336a8289a811f27588016aee39a5ee97440355 100644
--- a/config/settings/container/base.py
+++ b/config/settings/container/base.py
@@ -257,6 +257,9 @@ DATABASES = {
         'HOST': os.environ.get('DJANGO_DB_HOST', ''),
         'PORT': os.environ.get('DJANGO_DB_PORT', ''),
         'CONN_MAX_AGE': 3600,
+        'TEST' : {
+            'NAME': 'gracedb_test_db',
+        },
     },
 }
 
@@ -368,6 +371,7 @@ if (len(LVALERT_OVERSEER_INSTANCES) == 2):
 # Use full client certificate to authenticate
 REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
     'api.backends.GraceDbAuthenticatedAuthentication',
+    'api.backends.GraceDbSciTokenAuthentication',
     'api.backends.GraceDbX509FullCertAuthentication',
     'api.backends.GraceDbBasicAuthentication',
 )
@@ -398,3 +402,6 @@ LOGGING['loggers']['django.request']['handlers'].append('mail_admins')
 # Turn off debug/error emails when in maintenance mode.
 if MAINTENANCE_MODE:
     LOGGING['loggers']['django.request']['handlers'].remove('mail_admins')
+
+# Set SciToken accepted audience to server FQDN
+SCITOKEN_AUDIENCE = ["https://" + SERVER_FQDN, "https://" + LIGO_FQDN]
diff --git a/config/settings/vm/base.py b/config/settings/vm/base.py
index aaa5d77fedec3c4a50f382834cbc469b43bcf49d..4ca0b7278eda1686e632b970a0f71a8d2b770b04 100644
--- a/config/settings/vm/base.py
+++ b/config/settings/vm/base.py
@@ -109,3 +109,5 @@ if (len(LVALERT_OVERSEER_INSTANCES) == 2):
                                 ENABLED[SEND_XMPP_ALERTS])
     INSTANCE_LIST = INSTANCE_LIST + IGWN_LIST
 
+# Set SciToken accepted audience to server FQDN
+SCITOKEN_AUDIENCE = ["https://" + SERVER_FQDN, "https://" + LIGO_FQDN]
diff --git a/gracedb/api/backends.py b/gracedb/api/backends.py
index 8c585db25ad26d3e50d939030bc841b68f3fa03a..f39b554f8fc20648725fac91971e79dedd2877d5 100644
--- a/gracedb/api/backends.py
+++ b/gracedb/api/backends.py
@@ -6,6 +6,7 @@ import re
 
 from django.contrib.auth import get_user_model, authenticate
 from django.conf import settings
+from django.contrib.auth.models import User
 from django.http import HttpResponseForbidden
 from django.utils import timezone
 from django.utils.http import unquote, unquote_plus
@@ -17,6 +18,10 @@ from rest_framework import authentication, exceptions
 from ligoauth.models import X509Cert
 from .utils import is_api_request
 
+import scitokens
+from jwt import InvalidTokenError
+from scitokens.utils.errors import SciTokensException
+
 # Set up logger
 logger = logging.getLogger(__name__)
 
@@ -64,6 +69,52 @@ class GraceDbBasicAuthentication(authentication.BasicAuthentication):
         return user_auth_tuple
 
 
+class GraceDbSciTokenAuthentication(authentication.BasicAuthentication):
+
+    def authenticate(self, request, public_key=None):
+        # Get token from header
+        try:
+            bearer = request.headers["Authorization"]
+        except KeyError:
+            return None
+        auth_type, serialized_token = bearer.split()
+        if  auth_type != "Bearer":
+            return None
+
+        # Deserialize token
+        try:
+            token = scitokens.SciToken.deserialize(
+                serialized_token,
+                # deserialize all tokens, enforce audience later
+                audience={"ANY"} | set(settings.SCITOKEN_AUDIENCE),
+                public_key=public_key,
+            )
+        except (InvalidTokenError, SciTokensException) as exc:
+            return None
+
+        # Enforce scitoken logic
+        enforcer = scitokens.Enforcer(
+            settings.SCITOKEN_ISSUER,
+            audience = settings.SCITOKEN_AUDIENCE,
+        )
+
+        authz, path = settings.SCITOKEN_SCOPE.split(":", 1)
+        if not enforcer.test(token, authz, path):
+            return None
+
+        # Get username from token 'Subject' claim.
+        try:
+            user = User.objects.get(username=token['sub'])
+        except User.DoesNotExist:
+            return None
+
+        if not user.is_active:
+            raise exceptions.AuthenticationFailed(
+                _('User inactive or deleted'))
+
+        return (user, None)
+
+
 class GraceDbX509Authentication(authentication.BaseAuthentication):
     """
     Authentication based on X509 certificate subject.
diff --git a/gracedb/api/tests/test_backends.py b/gracedb/api/tests/test_backends.py
index ed61130ad58cf9d4a3e5db0e28594675cc2e93f5..fda771930ff2e0476d748079c99503e19552a67f 100644
--- a/gracedb/api/tests/test_backends.py
+++ b/gracedb/api/tests/test_backends.py
@@ -12,13 +12,21 @@ from user_sessions.middleware import SessionMiddleware
 
 from api.backends import (
     GraceDbBasicAuthentication, GraceDbX509Authentication,
-    GraceDbAuthenticatedAuthentication,
+    GraceDbSciTokenAuthentication, GraceDbAuthenticatedAuthentication,
 )
 from api.tests.utils import GraceDbApiTestBase
 from api.utils import api_reverse
 from ligoauth.middleware import ShibbolethWebAuthMiddleware
 from ligoauth.models import X509Cert
 
+import scitokens
+import time
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
+from core.tests.utils import GraceDbTestBase
+from django.test import override_settings
+
 
 # Make sure to test password expiration
 class TestGraceDbBasicAuthentication(GraceDbApiTestBase):
@@ -134,6 +142,137 @@ class TestGraceDbBasicAuthentication(GraceDbApiTestBase):
             user, other = self.backend_instance.authenticate(request)
 
 
+class TestGraceDbSciTokenAuthentication(GraceDbTestBase):
+    """Test SciToken auth backend for API"""
+
+    TEST_ISSUER = "local"
+    TEST_AUDIENCE = ["TEST"]
+    TEST_SCOPE = "read:/GraceDB"
+
+    @classmethod
+    def setUpClass(cls):
+        super(TestGraceDbSciTokenAuthentication, cls).setUpClass()
+
+        # Attach request factory to class
+        cls.backend_instance = GraceDbSciTokenAuthentication()
+        cls.factory = APIRequestFactory()
+
+    @classmethod
+    def setUpTestData(cls):
+        super(TestGraceDbSciTokenAuthentication, cls).setUpTestData()
+
+    def setUp(self):
+        self._private_key = generate_private_key(
+            public_exponent=65537,
+            key_size=2048,
+            backend=default_backend()
+        )
+        self._public_key = self._private_key.public_key()
+        self._public_pem = self._public_key.public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.SubjectPublicKeyInfo
+        )
+        keycache = scitokens.utils.keycache.KeyCache.getinstance()
+        keycache.addkeyinfo("local", "sample_key", self._private_key.public_key())
+        now = int(time.time())
+        self._token = scitokens.SciToken(key = self._private_key, key_id="sample_key")
+        self._token.update_claims({
+        "iss": self.TEST_ISSUER,
+        "aud": self.TEST_AUDIENCE,
+        "scope": self.TEST_SCOPE,
+        "sub": str(self.internal_user),
+        })
+        self._serialized_token = self._token.serialize(issuer = "local")
+        self._no_kid_token = scitokens.SciToken(key = self._private_key)
+
+    @override_settings(
+        SCITOKEN_ISSUER="local",
+        SCITOKEN_AUDIENCE=["TEST"],
+    )
+    def test_user_authenticate_to_api_with_scitoken(self):
+        """User can authenticate to API with valid Scitoken"""
+        # Set up request
+        request = self.factory.get(api_reverse('api:root'))
+        token_str = 'Bearer ' + self._serialized_token.decode()
+        request.headers = {'Authorization': token_str}
+
+        # Authentication attempt
+        user, other = self.backend_instance.authenticate(request, public_key=self._public_pem)
+
+        # Check authenticated user
+        self.assertEqual(user, self.internal_user)
+
+    @override_settings(
+        SCITOKEN_ISSUER="local",
+        SCITOKEN_AUDIENCE=["TEST"],
+    )
+    def test_user_authenticate_to_api_without_scitoken(self):
+        """User can authenticate to API without valid Scitoken"""
+        # Set up request
+        request = self.factory.get(api_reverse('api:root'))
+
+        # Authentication attempt
+        resp = self.backend_instance.authenticate(request, public_key=self._public_pem)
+
+        # Check authentication response
+        assert resp == None
+
+    @override_settings(
+        SCITOKEN_ISSUER="local",
+        SCITOKEN_AUDIENCE=["TEST"],
+    )
+    def test_user_authenticate_to_api_with_wrong_audience(self):
+        """User can authenticate to API with invalid Scitoken audience"""
+        # Set up request
+        request = self.factory.get(api_reverse('api:root'))
+        self._token["aud"] = "https://somethingelse.example.com"
+        serialized_token = self._token.serialize(issuer = "local")
+        token_str = 'Bearer ' + serialized_token.decode()
+        request.headers = {'Authorization': token_str}
+
+        # Authentication attempt
+        resp = self.backend_instance.authenticate(request, public_key=self._public_pem)
+
+        # Check authentication response
+        assert resp == None
+
+    @override_settings(
+        SCITOKEN_ISSUER="local",
+        SCITOKEN_AUDIENCE=["TEST"],
+    )
+    def test_user_authenticate_to_api_with_expired_scitoken(self):
+        """User can authenticate to API with valid Scitoken"""
+        # Set up request
+        request = self.factory.get(api_reverse('api:root'))
+        serialized_token = self._token.serialize(issuer = "local", lifetime=-1)
+        token_str = 'Bearer ' + serialized_token.decode()
+        request.headers = {'Authorization': token_str}
+
+        # Authentication attempt
+        resp = self.backend_instance.authenticate(request, public_key=self._public_pem)
+
+        # Check authentication response
+        assert resp == None
+
+    @override_settings(
+        SCITOKEN_ISSUER="local",
+        SCITOKEN_AUDIENCE=["TEST"],
+    )
+    def test_inactive_user_authenticate_to_api_with_scitoken(self):
+        """Inactive user can't authenticate with valid Scitoken"""
+        # Set internal user to inactive
+        self.internal_user.is_active = False
+        self.internal_user.save(update_fields=['is_active'])
+
+        # Set up request
+        request = self.factory.get(api_reverse('api:root'))
+        token_str = 'Bearer ' + self._serialized_token.decode()
+        request.headers = {'Authorization': token_str}
+
+        # Authentication attempt should fail
+        with self.assertRaises(exceptions.AuthenticationFailed):
+            user, other = self.backend_instance.authenticate(request, public_key=self._public_pem)
+
 class TestGraceDbX509Authentication(GraceDbApiTestBase):
     """Test X509 certificate auth backend for API"""
 
diff --git a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py
index eb207ffc5a9fd83e7352a4e98c1ceaaa2c812d39..b390e7c06c5c1cbd68ab308fb109c9e4fde0d568 100644
--- a/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py
+++ b/gracedb/ligoauth/management/commands/update_user_accounts_from_ligo_ldap.py
@@ -11,6 +11,10 @@ from django.core.management.base import BaseCommand, CommandError
 from ligoauth.models import X509Cert, AuthGroup, \
                             AuthorizedLdapMember, GenericLdapUser
 
+from events.models import Event, EventLog
+from superevents.models import Log
+from alerts.models import Notification
+
 UserModel = get_user_model()
 
 
@@ -64,9 +68,92 @@ class LdapPersonResultProcessor(object):
         pass
 
     def get_or_create_user(self):
+
         if not hasattr(self, 'user_data'):
             self.extract_user_attributes()
 
+        # Kagra members used to use the the 'mail' ldap attribute as a username, 
+        # to be consistent with LIGO members. However, for scitokens, a kagra user's
+        # eppn is used in the same field as a ligo member's @ligo.org email. So, 
+        # if the eppn attribute exists from the ldap data (which is only polled for
+        # kagra, not ligo), check for a user with the email, if it exists, then change
+        # the user's username. If not, then do nothing to the user, but change the 'username'
+        # user_data attribute either way. This is kind of hacky, but in theory this should 
+        # only actually run once. 
+
+        # This loop is only for kagra folks, as it's set up:
+        if 'eduPersonPrincipalName' in self.ldap_result.keys():
+            # Check for an existing user whose username is their eppn:
+            kagra_user = UserModel.objects.filter(username=
+                    self.user_data['email'])
+            if kagra_user.exists():
+                kagra_user = kagra_user.first()
+                print("Kagra user {} exists. Implementing logic to merge with {}".format(
+                    kagra_user.username, self.user_data['username']))
+
+                # Kagra shibbi users don't typically have annotations since they're
+                # just logging in through the web. but they DO have notifications 
+                # set up. So. check if API (kagra_user) has any logs, eventlogs, or events
+                # to their name. If so, change the logs to the shibboleth account. Again, 
+                # this unlikely and should only matter the first time this script gets run. 
+
+                # Get this shibboleth user:
+                shibbi_user = UserModel.objects.filter(username=
+                    self.user_data['username'])
+                if shibbi_user.exists():
+                    shibbi_user = shibbi_user.first()
+                    print("Shibboleth user {} exists. Checking for API user annotations.")
+
+                    # Check for uploaded events:
+                    kagra_events = Event.objects.filter(submitter=kagra_user)
+                    if kagra_events.exists():
+                        for e in kagra_events:
+                            print("changing submitter for event {} to {}".format(
+                                e, shibbi_user))
+                            e.submitter = shibbi_user
+                            e.save()
+                    else:
+                        print("No events uploaded by user {}".format(kagra_user))
+
+                    # Check for event log annotations:
+                    kagra_eventlogs = EventLog.objects.filter(issuer=kagra_user)
+                    if kagra_eventlogs.exists():
+                        for e in kagra_eventlogs:
+                            print("changing issuer for eventlog {} to {}".format(
+                                e, shibbi_user))
+                            e.issuer = shibbi_user
+                            e.save()
+                    else:
+                        print("No eventlogs annotated by user {}".format(kagra_user))
+
+                    # Check for superevent log annotations:
+                    kagra_seventlogs = Log.objects.filter(issuer=kagra_user)
+                    if kagra_seventlogs.exists():
+                        for s in kagra_seventlogs:
+                            print("changing issuer for supereventlog {} to {}".format(
+                                s, shibbi_user))
+                            s.issuer = shibbi_user
+                            s.save()
+                    else:
+                        print("No supereventlogs annotated by user {}".format(kagra_user))
+
+                    # So the old account's annotations have been transferred. Print 
+                    # stats for the shibbi account and then delete the old one:
+
+                    print("Shibboleth-created account {shib} remains with {e} events, {el} event logs, {se} superevent logs, and {n} alert notifications". format(
+                        shib=shibbi_user.username,
+                        e=Event.objects.filter(submitter=shibbi_user).count(),
+                        el=EventLog.objects.filter(issuer=shibbi_user).count(),
+                        se=Log.objects.filter(issuer=shibbi_user).count(),
+                        n=Notification.objects.filter(user=shibbi_user).count()))
+                    print("Deleting user {}".format(kagra_user))
+                    kagra_user.delete()
+                else:
+                    print("No shibboleth user {} exists. Changing username.".format(
+                        self.user_data['username']))
+                    kagra_user.username = self.user_data['username']
+                    kagra_user.save()
+
         # Determine if users exist
         user_exists = UserModel.objects.filter(username=
             self.user_data['username']).exists()
@@ -376,8 +463,6 @@ class LdapRobotResultProcessor(LdapPersonResultProcessor):
 
 class LdapKagraResultProcessor(LdapPersonResultProcessor):
 
-    #def __init__(self, ldap_member_name='Communities:LSCVirgoLIGOGroupMembers', 
-    #    *args, **kwargs):
     def __init__(self, ldap_dn, ldap_result, ldap_connection=None,
         verbose=True, stdout=None, 
         ldap_member_name='gw-astronomy:KAGRA-LIGO:members', 
@@ -411,7 +496,7 @@ class LdapKagraResultProcessor(LdapPersonResultProcessor):
             'email': self.ldap_result['mail'][0].decode('utf-8'),
             'is_active': bool(self.ldap_connection.lvc_group.authorizedldapmember_set.all() & 
                               self.ldap_memberships),
-            'username': self.ldap_result['mail'][0].decode('utf-8'),
+            'username': self.ldap_result['eduPersonPrincipalName'][0].decode('utf-8'),
         }
     def update_user_certificates(self):
 
@@ -545,6 +630,7 @@ class KagraPeopleLdap(LigoPeopleLdap):
         'voPersonCertificateDN',
         'mail',
         'isMemberOf',
+        'eduPersonPrincipalName',
     ]
     group_names = ['internal_users']
     user_processor_class = LdapKagraResultProcessor
diff --git a/gracedb/migrations/auth/0028_case_insensitive_username.py b/gracedb/migrations/auth/0028_case_insensitive_username.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2fc59810f0689268447c9518183bf0ed4c4e155
--- /dev/null
+++ b/gracedb/migrations/auth/0028_case_insensitive_username.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+from django.contrib.postgres import fields
+from django.contrib.postgres.operations import CITextExtension
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0027_auto_20211117_2338'),
+    ]
+
+    # No database changes; modifies validators and error_messages (#13147).
+    operations = [
+        CITextExtension(),
+        migrations.AlterField(
+            model_name='user',
+            name='username',
+            field=fields.CICharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username'),
+        ),
+    ]
diff --git a/requirements.txt b/requirements.txt
index a7f282f80560503303279e9809865b51057800cf..df876e48a737f429ce3b8cb53a6ac1c7bfcd52cb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,9 +37,11 @@ packaging==17.1
 phonenumbers==8.10.22
 plotly==4.14.3
 psycopg2==2.8.6
+PyJWT==2.3.0
 python-ldap==3.3.1
 python3-memcached==1.51
 scipy==1.6.1
+scitokens==1.6.2
 sentry-sdk==0.7.10
 service_identity==21.1.0
 simplejson==3.15.0