OTP-Based JWT Authentication with AWS SNS in Django REST Framework

In this blog, we’ll build a secure OTP-based login system using AWS SNS for sending OTPs via SMS and Django REST Framework (DRF) for building the API. We'll also issue JWT tokens using djangorestframework-simplejwt upon OTP verification — all powered by per-user TOTP using pyotp.

Why OTP-Based Authentication?

  • OTP (One-Time Password) login is widely used in modern applications, especially those that:

  • Avoid traditional passwords

  • Use phone number verification

  • Require fast and secure authentication

  • Want to improve UX with passwordless login

Tech Stack

  • Python 3.8+

  • Django 4+

  • Django REST Framework

  • djangorestframework-simplejwt (JWT handling)

  • AWS SNS (SMS delivery)

  • pyotp (OTP generation & verification)

  • PostgreSQL / SQLite (any Django-supported DB)

Install Required Packages

pip install django djangorestframework djangorestframework-simplejwt boto3 pyotp python-decouple

Add Apps in project settings.py

INSTALLED_APPS = [

    ...

    'rest_framework',

    'rest_framework_simplejwt',

    'appname',

]

Step 1: Configure AWS SNS

Create IAM User for SNS

Go to AWS Console

Navigate to IAM > Users > Add user

Username: otp-sender

Access type:  Programmatic access

Permissions: Attach AmazonSNSFullAccess

Download Access Key ID and Secret Access Key

 Set Environment Variables

Create a .env file in your project root:

AWS_ACCESS_KEY_ID=AKIAxxxxxxxxxxxx

AWS_SECRET_ACCESS_KEY=abcdxxxxxxxxxxxxxxx

AWS_DEFAULT_REGION=ap-south-1

settings.py:

from decouple import config

AWS_ACCESS_KEY_ID = config("AWS_ACCESS_KEY_ID")

AWS_SECRET_ACCESS_KEY = config("AWS_SECRET_ACCESS_KEY")

AWS_DEFAULT_REGION = config("AWS_DEFAULT_REGION")

Step 2: Extend Django User Model

models.py:

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin

from django.db import models

from pyotp import random_base32

class CustomUser(AbstractBaseUser, PermissionsMixin):

    phone_number = models.CharField(max_length=15, unique=True)

    secret_key = models.CharField(

        max_length=32,

        default=random_base32,

        editable=False,

        unique=True,

        verbose_name="Secret Key"

    )

    USERNAME_FIELD = 'phone_number'

Run migrations:

python manage.py makemigrations

python manage.py migrate

python manage.py runserver

Step 3: OTP Logic with pyotp and SNS

utils.py

import pyotp

import boto3

import os

def send_otp_via_sns(phone_number, secret_key):

    totp = pyotp.TOTP(secret_key)

    otp = totp.now()

    client = boto3.client(

        "sns",

        aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID"),

        aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),

        region_name=os.getenv("AWS_DEFAULT_REGION", "ap-south-1")

    )

    try:

        client.publish(

            PhoneNumber=phone_number,

            Message=f"Your OTP is {otp}"

        )

        return True

    except Exception as e:

        print("SNS Error:", e)

        return False

Step 4: Serializers

serializers.py

from rest_framework import serializers

class PhoneNumberSerializer(serializers.Serializer):

    phone_number = serializers.CharField()

class OTPVerifySerializer(serializers.Serializer):

    phone_number = serializers.CharField()

    otp = serializers.CharField()

Step 5: API Views

views.py

from rest_framework.views import APIView

from rest_framework.response import Response

from rest_framework import status

from django.contrib.auth import get_user_model

from .utils import send_otp_via_sns

from .serializers import PhoneNumberSerializer, OTPVerifySerializer

from rest_framework_simplejwt.tokens import RefreshToken

import pyotp

User = get_user_model()

class SendOTPView(APIView):

    def post(self, request):

        serializer = PhoneNumberSerializer(data=request.data)

        if serializer.is_valid():

            phone = serializer.validated_data['phone_number']

            try:

                user = User.objects.get(phone_number=phone)

            except User.DoesNotExist:

                return Response({"error": "User not found"}, status=404)

            success = send_otp_via_sns(phone, user.secret_key)

            return Response({"message": "OTP sent"} if success else {"error": "Failed to send OTP"})

        return Response(serializer.errors, status=400)

class VerifyOTPView(APIView):

    def post(self, request):

        serializer = OTPVerifySerializer(data=request.data)

        if serializer.is_valid():

            phone = serializer.validated_data['phone_number']

            otp = serializer.validated_data['otp']

            try:

                user = User.objects.get(phone_number=phone)

            except User.DoesNotExist:

                return Response({"error": "Invalid user"}, status=404)

            totp = pyotp.TOTP(user.secret_key)

            if totp.verify(otp, valid_window=1):

                refresh = RefreshToken.for_user(user)

                return Response({

                    "refresh": str(refresh),

                    "access": str(refresh.access_token)

                })

            return Response({"error": "Invalid or expired OTP"}, status=400)

        return Response(serializer.errors, status=400)

Step 6: URLs

urls.py

from django.urls import path

from .views import SendOTPView, VerifyOTPView

urlpatterns = [

    path("send-otp/", SendOTPView.as_view(), name="send_otp"),

    path("verify-otp/", VerifyOTPView.as_view(), name="verify_otp"),

]

Sample Request Flow

Step 1: Send OTP

POST /send-otp/

{

  "phone_number": "+919876543210"

}

An otp will be delivered to the provided phone number

Step 2: Verify OTP & Get JWT

POST /verify-otp/

{

  "phone_number": "+919876543210",

  "otp": "123456" #otp recieved

}

On successful verification will return refresh and access token

{

  "refresh": "jwt_refresh_token_here",

  "access": "jwt_access_token_here"

}

Reusing OTP Logic for Registration

In the current implementation, we are using OTP-based login by sending an OTP to the user's registered phone number and verifying it before issuing JWT tokens.

This same flow can also be used during user registration, by verifying the user's phone number before creating their account or activating it.