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.
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
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"),
]
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"
}
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.