A Complete User Authentication System Using Django REST framework and ReactJS


This project offers a complete user authentication system for both traditional email/password and social logins (Google, GitHub, etc.). It features email verification for new accounts and uses secure tokens to manage user sessions across the frontend and backend.



Table of Contents

  1. User and Account Data Models
  2. User Authentication Flow (Backend)
  3. Social Login Provider Integration
  4. Email Verification System
  5. PKCE (Proof Key for Code Exchange) Security
  6. API Communication Layer (Frontend)
  7. Frontend Authentication State Management



1. User and Account Data Models:

Our data models act as organized record-keeping system, meticulously detailing each user’s profile and their connections.

Let’s break down the three essential data models we’ll use for user authentication.



1. The User Model: Your Core Identity

The user model extends Django’s default one. It gets all the standard features (like username, password) and we can add new ones.

Here’s a look at our User model:

# File: backend/accounts/models.py

from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    # We make email unique so no two users can share the same email.
    email = models.EmailField(unique=True)
    # This will be False when a user first signs up, then True after verification.
    email_verified = models.BooleanField(default=False)

    # This tells Django that 'email' is a required field when creating a user.
    REQUIRED_FIELDS = ["email"]
Enter fullscreen mode

Exit fullscreen mode



2. The SocialAccount Model: Connecting to the World

When you log in with Google, Google tells our application “This is User X with ID Y.” We need a way to link “User X with ID Y from Google” to our internal User record. The SocialAccount model does exactly that.

Let’s look at the SocialAccount model:

# File: backend/accounts/models.py

from django.db import models
# ... User model defined above ...

class SocialAccount(models.Model):
    # Defines the possible social providers we support.
    PROVIDERS = (
        ("google","Google"),
        ("github","GitHub"),
        # ... other providers like Facebook, LinkedIn ...
    )
    # Links this social account to one of our internal User accounts.
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='social_accounts')
    # Stores which provider it is (e.g., "google" or "github").
    provider = models.CharField(max_length=20, choices=PROVIDERS)
    # Stores the unique ID given by the social provider.
    provider_uid = models.CharField(max_length=255)
    # Optional: To store any extra data from the social provider.
    extra_data = models.JSONField(default=dict, blank=True)

    class Meta:
        # Ensures that a user can only have one Google account, or one GitHub account.
        unique_together = ("provider","provider_uid")
Enter fullscreen mode

Exit fullscreen mode



3. The EmailVerificationToken Model: Confirming Identities

The EmailVerificationToken model temporarily stores a special code (token) that we send to the user’s email.

Here’s the EmailVerificationToken model:

# File: backend/accounts/models.py

from django.db import models
import uuid
from django.utils import timezone
# ... User model defined above ...

class EmailVerificationToken(models.Model)
    # Links this token to a specific User.
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_tokens')
    # A unique code generated for verification, like a secret key.
    token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    # Records when the token was created (useful for expiration).
    created_at = models.DateTimeField(default=timezone.now)
    # Becomes True once the token has been successfully used.
    is_used = models.BooleanField(default=False)
Enter fullscreen mode

Exit fullscreen mode

Here is the detailed explanation of User model:
https://github.com/devesh111/Complete-User-Authentication/blob/main/01_user_and_account_data_models_.md




2. User Authentication Flow (Backend):

The backend authentication flow involves several stages, each with a specific purpose:



1. Registration:

The process where a new user creates an account. They provide basic information like email and a password.

  • Endpoint: /api/auth/register/ (This is where the frontend sends registration requests)
  • Method: POST (We are sending data to create something new)

Example Input (from Frontend to Backend):

{
    "username": "newuser123",
    "email": "newuser@example.com",
    "password": "StrongPassword123!"
}
Enter fullscreen mode

Exit fullscreen mode



Code Snapshot: Register View (RegisterView)
# File: backend/accounts/views.py

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
# ... other imports ...

class RegisterView(APIView):
    permission_classes = [AllowAny] # Anyone can register
    def post(self, request):
        # 1. Take incoming data and validate it using a serializer
        ser = RegisterSerializer(data=request.data)
        ser.is_valid(raise_exception=True) # If invalid, an error is returned

        # 2. Save the new user to the database (password gets hashed here)
        user = ser.save() 

        # 3. Create an email verification token for the new user
        token_obj = EmailVerificationToken.objects.create(user=user)

        # 4. Send the verification email (details in Chapter 4)
        send_verification_email(user.email, str(token_obj.token))

        # 5. Send a success response back to the frontend
        return Response(
            {"message": "Registered. Please verify email.", "verification_token": str(token_obj.token)}, 
            status=201 # HTTP 201 means "Created"
        )
Enter fullscreen mode

Exit fullscreen mode



Code Snapshot: Register Serializer (RegisterSerializer)
# File: backend/accounts/serializers.py

from rest_framework import serializers
from django.contrib.auth.hashers import make_password
from .models import User # Our custom User model from Chapter 1

class RegisterSerializer(serializers.ModelSerializer):
    # This field is only for writing (when creating a user), not for reading.
    # We require a minimum password length.
    password = serializers.CharField(write_only=True, min_length=8) 

    class Meta:
        model = User # This serializer works with our User model
        fields = ("username","email","password") # Fields we accept

    # This method is automatically called when .save() is used on the serializer.
    def create(self, validated_data):
        # Before saving, hash the password for security!
        validated_data['password'] = make_password(validated_data['password'])
        # Create and return the user object
        return User.objects.create(**validated_data)
Enter fullscreen mode

Exit fullscreen mode



2. Login:

Users prove their identity (e.g., by entering their email and password) to gain access to their account.

  • Endpoint: /api/auth/login/
  • Method: POST

Example Input:

{
    "email": "existinguser@example.com",
    "password": "StrongPassword123!"
}
Enter fullscreen mode

Exit fullscreen mode



Code Snapshot: Login View (LoginView)
# File: backend/accounts/views.py

# ... other imports ...
from rest_framework_simplejwt.tokens import RefreshToken # For generating tokens

class LoginView(APIView):
    permission_classes = [AllowAny]
    def post(self, request):
        # 1. Validate credentials using the LoginSerializer
        ser = LoginSerializer(data=request.data)
        ser.is_valid(raise_exception=True)

        # 2. Get the authenticated user object from the serializer
        user = ser.validated_data['user']

        # 3. Generate a refresh token for the user
        refresh = RefreshToken.for_user(user)

        # 4. Return both the access token and refresh token
        return Response({
            'access': str(refresh.access_token), 
            'refresh': str(refresh), 
            'user': {'id': user.id, 'username': user.username, 'email': user.email, 'email_verified': user.email_verified}
        })
Enter fullscreen mode

Exit fullscreen mode



Code Snapshot: Login Serializer (LoginSerializer)
# File: backend/accounts/serializers.py

from rest_framework import serializers
from django.contrib.auth import authenticate # Django's built-in password checker
from .models import User # Our custom User model

class LoginSerializer(serializers.Serializer):
    email = serializers.EmailField()
    password = serializers.CharField(write_only=True)

    # The validate method is where we check email and password.
    def validate(self, attrs):
        email = attrs.get('email')
        password = attrs.get('password')

        # Try to find the user by email first
        try:
            user = User.objects.get(email=email)
            username = user.username # authenticate needs username, but we login with email
        except User.DoesNotExist:
            raise serializers.ValidationError('Invalid credentials')

        # Use Django's authenticate function to check password
        user = authenticate(username=username, password=password) 

        if not user:
            raise serializers.ValidationError('Invalid credentials')

        # If successful, add the user object to the validated data
        attrs['user'] = user 
        return attrs
Enter fullscreen mode

Exit fullscreen mode



3. Session Management (Access and Refresh Tokens):

Once logged in, users need a way to stay logged in without re-entering their credentials for every action. We use special “tokens” for this.

  • Access Token: Sent with almost every request to access protected data. It’s short-lived for security. If it’s stolen, it’s only useful for a short time.

  • Refresh Token: Used only to get a new Access Token when the old one expires. It’s longer-lived and stored more securely (in an HttpOnly cookie, meaning JavaScript can’t touch it, which helps prevent certain attacks).



Refreshing the Access Token:

When the Access Token expires, the frontend uses the Refresh Token to get a new one.

  • Endpoint: /api/auth/token/refresh/
  • Method: POST


Code Snapshot: Cookie Token Refresh View (CookieTokenRefreshView)
# File: backend/accounts/views.py

from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import InvalidToken
from rest_framework.response import Response # Import Response

class CookieTokenRefreshView(APIView): # Changed from TokenRefreshView for cookie handling
    def post(self, request):
        # 1. Get the refresh token from the HttpOnly cookie
        refresh_token = request.COOKIES.get("refresh_token")
        if not refresh_token:
            return Response({"detail": "No refresh token"}, status=400)

        try:
            # 2. Use simplejwt's RefreshToken to validate and get a new access token
            refresh = RefreshToken(refresh_token)
            access_token = str(refresh.access_token)
            return Response({"access": access_token})
        except Exception: # Catch any error during token refresh
            raise InvalidToken("Invalid refresh token")
Enter fullscreen mode

Exit fullscreen mode



Accessing Protected Resources:

Once a user has a valid Access Token, they can send it with requests to view their profile, update settings, or do anything else that requires them to be logged in.

  • Endpoint: /api/auth/me/ (or any other protected endpoint)
  • Method: GET
  • Input: Access Token in the Authorization header (Authorization: Bearer )


Code Snapshot: Me View (MeView)
# File: backend/accounts/views.py

from rest_framework.permissions import IsAuthenticated # Only authenticated users

class MeView(APIView):
    permission_classes = [IsAuthenticated] # This ensures only logged-in users can access
    def get(self, request):
        # request.user is automatically populated by Django if the token is valid
        u = request.user 
        return Response({
            'id': u.id, 
            'username': u.username, 
            'email': u.email, 
            'email_verified': u.email_verified, 
            'providers': list(u.social_accounts.values_list('provider', flat=True))
        })
Enter fullscreen mode

Exit fullscreen mode



4. Logout:

Users explicitly end their session, removing their access.

  • Endpoint: /api/auth/logout/
  • Method: POST


Code Snapshot: Logout View (LogoutView)
# File: backend/accounts/views.py

class LogoutView(APIView):
    def post(self, request):
        response = Response({"detail": "Logged out"})
        # This is key: It tells the browser to delete the "refresh_token" cookie.
        response.delete_cookie("refresh_token") 
        return response
Enter fullscreen mode

Exit fullscreen mode



5. Social Login:

Allowing users to sign up or log in using existing accounts from services like Google or GitHub.



Backend API Endpoints:

Here’s a quick reference for the main doors to our backend authentication system:

Endpoint Method Purpose Requires Authentication?
/api/auth/register/ POST Create a new user account. No
/api/auth/verify-email/ GET Confirm a user’s email using a token. No
/api/auth/login/ POST Authenticate user and issue Access/Refresh tokens. No
/api/auth/token/refresh/ POST Exchange a Refresh Token for a new Access Token. No (uses cookie)
/api/auth/me/ GET Get current user’s profile details. Yes
/api/auth/logout/ POST Invalidate user’s session (delete refresh token cookie). No
/api/auth/social// POST Handle social logins (Google, GitHub, etc.) to get our tokens. No

You can find a detailed explanation of user authentication flow here: https://github.com/devesh111/Complete-User-Authentication/blob/main/02_user_authentication_flow_backend_.md




3. Social Login Provider Integration:

Integrating social logins involves a few important ideas:



1. Social Login Providers:

These are the third-party services like Google, GitHub, Facebook, LinkedIn. Each has its own way of verifying users and sharing information.



2. OAuth 2.0:

This is the standard “language” or protocol our application uses to talk to social providers. It’s not about our app logging into Google, but about Google giving our app permission to verify a user’s identity.



3. Authorization Code Flow:

A common and secure way OAuth 2.0 works. The user first gives permission on the social provider’s site, which then gives our app a temporary “code.” Our backend exchanges this code for an “access token” (a secret key) to get user data.



Receiving the Request and Initial Validation:
# File: backend/accounts/views.py

# ... (imports) ...
from .providers import PROVIDERS # A dictionary of our social provider handlers

class SocialAuthView(APIView):
    permission_classes = [AllowAny]

    @transaction.atomic # Ensures all database changes happen together or not at all
    def post(self, request, provider):
        if provider not in PROVIDERS:
            return Response({"detail": "Unsupported provider"}, status=400)

        # Use a serializer to validate the incoming data (code, access_token, etc.)
        serializer = SocialAuthSerializer(data={**request.data, 'provider': provider})
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data

        # Extract relevant info from the validated data
        code = data.get('code')
        # ... other tokens like access_token, id_token, code_verifier
        # ... (further logic to follow) ...
Enter fullscreen mode

Exit fullscreen mode



Exchanging the Authorization Code for Provider Tokens:
# File: backend/accounts/views.py (inside SocialAuthView.post)

        Provider = PROVIDERS[provider] # Get the specific provider handler (e.g., GoogleProvider)

        # If a 'code' was provided (typical for the secure OAuth flow)
        if code and not access_token: # 'access_token' might be directly provided for some flows
            try:
                # Specific logic for Google to exchange the code
                if provider == 'google':
                    payload = {
                        'code': code, 
                        'client_id': settings.GOOGLE_CLIENT_ID, 
                        'client_secret': settings.GOOGLE_CLIENT_SECRET, 
                        'redirect_uri': settings.OAUTH_REDIRECT_URI, 
                        'grant_type': 'authorization_code',
                        # 'code_verifier' for PKCE flow (Chapter 5)
                    }
                    r = requests.post('https://oauth2.googleapis.com/token', data=payload)
                    r.raise_for_status(); tok = r.json()
                    access_token = tok.get('access_token'); id_token = tok.get('id_token')

                # Similar 'elif' blocks would exist for GitHub, Facebook, LinkedIn...
                # For brevity, let's just show Google.
                # (See backend/accounts/providers.py for more detailed exchange logic)

            except requests.RequestException as e:
                return Response({'detail': 'Code exchange failed', 'error': str(e)}, status=400)
        # ... (further logic to follow) ...
Enter fullscreen mode

Exit fullscreen mode



4. Access Tokens and ID Tokens (from Social Provider):

These are temporary keys issued by the social provider (e.g., Google) that allow our backend to fetch the user’s profile details.

# File: backend/accounts/views.py (inside SocialAuthView.post, after code exchange)

        # If we have id_token or access_token from the provider, fetch user's profile
        try:
            if provider == 'google' and id_token:
                # Google can fetch user info directly from id_token
                uid, profile = Provider.fetch_user(id_token=id_token)
            else:
                # Other providers, or Google without id_token, use access_token
                uid, profile = Provider.fetch_user(access_token=access_token)

        except Exception as e:
            return Response({'detail': 'Failed to fetch user from provider', 'error': str(e)}, status=400)

        email = profile.get('email')
        name = profile.get('name') or ''
        # ... (further logic to follow) ...
Enter fullscreen mode

Exit fullscreen mode



5. Our SocialAccount Model:

Once we verify a user via a social provider, we need to link their social ID to our internal User record using the SocialAccount model we discussed earlier in User and Account Data Models.

# File: backend/accounts/views.py (inside SocialAuthView.post, after fetching profile)

        try:
            # 1. Try to find an existing SocialAccount for this provider and UID
            social = SocialAccount.objects.select_related('user').get(provider=provider, provider_uid=uid)
            user = social.user # If found, we have an existing user

        except SocialAccount.DoesNotExist:
            user = None
            # 2. If no SocialAccount, try to find a User by email
            if email:
                user = User.objects.filter(email=email).first()

            # 3. If still no user, create a brand new User
            if not user:
                base_username = (name or email or f"{provider}_{uid}").split('@')[0].replace(' ', '').lower() or f"user_{str(uid)[:6]}"
                username = base_username; i = 1
                while User.objects.filter(username=username).exists(): # Ensure unique username
                    i += 1; username = f"{base_username}{i}"

                user = User.objects.create(
                            username = username, 
                            email = email or f"{provider}_{uid}@example.com", 
                            email_verified = bool(email) # Email is verified if provided by social login
                        )
            # 4. Create a new SocialAccount linking the User to this social ID
            SocialAccount.objects.create( 
                user=user, 
                provider=provider, 
                provider_uid=uid, 
                extra_data=profile
            )
        # ... (further logic to follow) ...
Enter fullscreen mode

Exit fullscreen mode



6. Issuing Our Application’s Tokens:

Regardless of whether the user was new or existing, at this point, we have identified them in our system. Now, we issue our own application’s access and refresh tokens so they can securely interact with our backend.

# File: backend/accounts/views.py (inside SocialAuthView.post, after database operations)

        # Generate our application's Refresh and Access Tokens for the user
        refresh = RefreshToken.for_user(user)
        access_token = str(refresh.access_token)
        refresh_token = str(refresh)

        response =  Response({
            'access': access_token, 
            'refresh': refresh_token, 
            'user': {
                'id': user.id, 
                'username': user.username, 
                'email': user.email, 
                'email_verified': user.email_verified
            }
        })

        # Store the refresh token in an HttpOnly cookie for security
        response.set_cookie(
            key="refresh_token",
            value=refresh_token,
            httponly=True,
            secure=not settings.DEBUG,  # Only secure in production
            samesite="Lax",
            max_age=14 * 24 * 60 * 60,  # 2 weeks
        )
        return response
Enter fullscreen mode

Exit fullscreen mode



Configuration for Social Logins (config.yaml & settings.py):

These sensitive values are stored in backend/config.yaml (which is loaded into backend/social_api/settings.py):

# File: backend/sample.config.yaml (snippet)

# ... other settings ...

GOOGLE_CLIENT_ID: "your-google-client-id" # Get this from Google Developer Console
GOOGLE_CLIENT_SECRET: "your-google-client-secret" # Get this from Google Developer Console

GITHUB_CLIENT_ID: "your-github-client-id" # Get this from GitHub Developer Settings
GITHUB_CLIENT_SECRET: "your-github-client-secret" # Get this from GitHub Developer Settings

# ... similar settings for Facebook, LinkedIn ...

OAUTH_REDIRECT_URI: "http://localhost:5173/auth/callback" # Crucial! This is where social providers redirect users back to our frontend.
Enter fullscreen mode

Exit fullscreen mode



The Backend’s Social Login Endpoint: SocialAuthView

Our backend has a special API endpoint dedicated to handling social login requests:

Example Input (Frontend to Backend, for Google):

{
    "code": "4/0Ad....some_long_google_code....",
    "provider": "google"
    // "code_verifier": "..." (for PKCE, covered later in this tutorial)
}
Enter fullscreen mode

Exit fullscreen mode

What the Backend Returns (Output):

{
    "access": "eyJhbGciOiJIUzI1Ni...", // Our app's access token
    "refresh": "eyJhbGciOiJIUzI1Ni...", // Our app's refresh token
    "user": {
        "id": 123,
        "username": "googlename",
        "email": "googleuser@example.com",
        "email_verified": true
    }
}
Enter fullscreen mode

Exit fullscreen mode

You can find a detailed explanation of Social Login Provider Integration here:
https://github.com/devesh111/Complete-User-Authentication/blob/main/03_social_login_provider_integration_.md




4. Email Verification System:

This system involves a few simple but important ideas:



1. Unique Token:

A secret, one-time-use code generated when a user registers. This code is unique to each user and each verification attempt.

# File: backend/accounts/models.py 

import uuid # Used to generate unique tokens
# ... other imports ...

class EmailVerificationToken(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_tokens')
    token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
    created_at = models.DateTimeField(default=timezone.now)
    is_used = models.BooleanField(default=False)

Enter fullscreen mode

Exit fullscreen mode

And this is how it’s created in the RegisterView:

#File: backend/accounts/views.py (snippet from RegisterView)

from .models import EmailVerificationToken
from .emails import send_verification_email # We'll see this next

class RegisterView(APIView):
    # ...
    def post(self, request):
        ser = RegisterSerializer(data=request.data)
        ser.is_valid(raise_exception=True)
        user = ser.save() # User is created, email_verified=False by default

        # Create a unique token for this new user
        token_obj = EmailVerificationToken.objects.create(user=user)

        # Send the email with this token
        send_verification_email(user.email, str(token_obj.token))

        return Response(
            {"message": "Registered. Please verify email."}, 
            status=201
        )
Enter fullscreen mode

Exit fullscreen mode



2. Verification Email:

An email sent to the user’s registered address, containing the unique token, usually embedded in a special link.

# File: backend/accounts/emails.py

from django.core.mail import send_mail
from django.conf import settings # To get settings like HOST and sender email

def send_verification_email(email: str, token: str):
    # Construct the full verification URL that the user will click
    verify_url = f"{settings.HOST}/api/auth/verify-email/?token={token}"

    subject = "Verify your email"
    body = f"Hello! Please click the link below to verify your email:\n\n{verify_url}\n\nIf you did not register for this service, please ignore this email."

    # Send the email using Django's built-in mail function
    send_mail(
        subject, 
        body, 
        settings.DEFAULT_FROM_EMAIL, # Our sending email address
        [email], # The user's email address
        fail_silently=True # Don't crash if email sending fails
    )
Enter fullscreen mode

Exit fullscreen mode



3. Verification Link:

The clickable URL in the email. When clicked, it sends the unique token back to our backend.

  • Endpoint: /api/auth/verify-email/
  • Method: GET (because the browser is just requesting to perform an action based on the URL)

Example Input (from Browser to Backend):

GET /api/auth/verify-email/?token=a1b2c3d4-e5f6-7890-1234-567890abcdef HTTP/1.1
Host: your-app-domain.com
Enter fullscreen mode

Exit fullscreen mode



What the Backend Does Internally:
# File: backend/accounts/views.py (VerifyEmailView)

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from .models import EmailVerificationToken, User # Need both models

class VerifyEmailView(APIView):
    permission_classes = [AllowAny] # Anyone can try to verify an email

    def get(self, request):
        # 1. Get the token from the URL's query parameters
        token = request.query_params.get('token')

        if not token: # Basic check if token exists
            return Response({"detail": "Token is missing"}, status=400)

        try:
            # 2. Find the token in the database and check if it hasn't been used yet
            t = EmailVerificationToken.objects.get(token=token, is_used=False)
        except EmailVerificationToken.DoesNotExist:
            # If token not found or already used, it's invalid
            return Response({"detail": "Invalid or expired token"}, status=400)

        # 3. Mark the token as used to prevent it from being used again
        t.is_used = True
        t.save(update_fields=['is_used']) # Save only this field

        # 4. Update the user's email_verified status to True
        user = t.user # Get the user linked to this token
        user.email_verified = True
        user.save(update_fields=['email_verified']) # Save only this field

        # 5. Send a success response
        return Response({"message": "Email successfully verified!"})
Enter fullscreen mode

Exit fullscreen mode

Once the token is verified, the user’s account status (specifically, their email_verified field) is updated to True.

You can find a detailed explanation of Email Verification System here:
https://github.com/devesh111/Complete-User-Authentication/blob/main/04_email_verification_system_.md




5. PKCE (Proof Key for Code Exchange) Security:

PKCE adds a clever two-step verification to the standard OAuth 2.0 Authorization Code Flow:

Let’s see how PKCE is implemented in our frontend and backend.



1. Frontend: Generating and Storing PKCE Values

Our frontend/src/utils/pkce.js file contains helper functions to create the code_verifier and code_challenge.

// File: frontend/src/utils/pkce.js

export function randomString(length = 64) {
    // Generates a random string of specified length (our code_verifier)
    const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._~";
    let result = "";
    const values = new Uint8Array(length);
    crypto.getRandomValues(values); // Get cryptographically strong random values
    for (let i = 0; i < length; i++)
        result += charset[values[i] % charset.length];
    return result;
}

export async function generateCodeChallenge(code_verifier) {
    // Hashes the code_verifier using SHA-256 (our code_challenge)
    const encoder = new TextEncoder();
    const data = encoder.encode(code_verifier);
    const digest = await crypto.subtle.digest("SHA-256", data);
    const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
        .replace(/\+/g, "-")
        .replace(/\//g, "_")
        .replace(/=+$/, ""); // URL-safe Base64 encoding
    return base64;
}
Enter fullscreen mode

Exit fullscreen mode

Now, let’s see how frontend/src/components/SocialLoginButtons.jsx uses these to prepare the social login URL:

// File: frontend/src/components/SocialLoginButtons.jsx (snippet)

import { randomString, generateCodeChallenge } from "../utils/pkce"; // Import our PKCE helpers

// ... other code ...

export default function SocialLoginButtons({ className = "" }) {
    // ... other code ...

    const openProvider = async (provider) => {
        const state = saveState({ provider }); // Stores info about this login attempt

        // --- PKCE Step 1 & 2: Generate verifier and challenge ---
        const code_verifier = randomString(64); // Our secret key
        const code_challenge = await generateCodeChallenge(code_verifier); // Its hashed fingerprint

        // --- PKCE Step 3: Save verifier for later ---
        // We link it to a 'nonce' (a random number in the 'state') to retrieve it later.
        const decoded = JSON.parse(atob(state));
        localStorage.setItem("pkce_" + decoded.nonce, code_verifier);

        let url = "";
        if (provider === "google") {
            const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
            const scope = encodeURIComponent("openid email profile");
            // --- PKCE Step 4: Include code_challenge in redirect URL ---
            url = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}&code_challenge=${code_challenge}&code_challenge_method=S256&access_type=offline&prompt=select_account`;
        } else if (provider === "github") {
            const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
            const scope = encodeURIComponent("read:user user:email");
            // --- PKCE Step 4: Include code_challenge for GitHub too ---
            url = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}&code_challenge=${code_challenge}&code_challenge_method=S256`;
        } else if (provider === "facebook") {
            // Facebook often uses a simpler flow, so PKCE might not be needed by default here
            const clientId = import.meta.env.VITE_FACEBOOK_CLIENT_ID;
            const scope = encodeURIComponent("email public_profile");
            url = `https://www.facebook.com/v17.0/dialog/oauth?client_id=${clientId}&redirect_uri=${redirectUri}&state=${state}&scope=${scope}&response_type=code`;
        } else if (provider === "linkedin") {
            const clientId = import.meta.env.VITE_LINKEDIN_CLIENT_ID;
            const scope = encodeURIComponent("r_liteprofile r_emailaddress");
            // --- PKCE Step 4: Include code_challenge for LinkedIn ---
            url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}&code_challenge=${code_challenge}&code_challenge_method=S256`;
        } else {
            alert("Unsupported provider");
            return;
        }

        window.location.href = url; // Redirect user to the social provider
    };

    // ... other code ...
}
Enter fullscreen mode

Exit fullscreen mode



2. Frontend: Sending code_verifier to our Backend

After the social provider (e.g., Google) redirects back to our frontend with the code, our frontend (OAuthCallback.jsx) retrieves the saved code_verifier and sends it to our backend.

// File: frontend/src/pages/OAuthCallback.jsx (snippet)

// ... imports ...

export default function OAuthCallback() {
    // ... state and hooks ...

    useEffect(() => {
        async function handle() {
            try {
                const qs = parseQuery(location.search);
                const { code, state, error, error_description } = qs;
                // ... error handling and state validation ...

                // --- PKCE Step 6: Retrieve saved code_verifier ---
                const code_verifier =
                    localStorage.getItem("pkce_" + nonce) || null; // Get it from local storage

                const apiUrlBase = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
                const body = { code };

                // --- PKCE Step 7: Add code_verifier to the request body if it exists ---
                if (code_verifier) body.code_verifier = code_verifier; 

                const res = await fetch(
                    `${apiUrlBase}/auth/social/${provider}/`,
                    {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        credentials: "include",
                        body: JSON.stringify(body), // Sends code and code_verifier to backend
                    },
                );

                // ... response handling and dispatching ...
                localStorage.removeItem("oauth_state_" + nonce);
                localStorage.removeItem("pkce_" + nonce); // Clean up the stored verifier
                // ... redirect ...
            } catch (e) { /* ... error handling ... */ }
        }
        handle();
    }, []);

    // ... JSX ...
}
Enter fullscreen mode

Exit fullscreen mode



3. Backend: Using code_verifier for the Token Exchange

Finally, our backend (SocialAuthView in backend/accounts/views.py) receives the code and code_verifier from our frontend. It then includes the code_verifier when making its own request to the social provider’s token endpoint.

# File: backend/accounts/views.py (snippet from SocialAuthView.post)

# ... imports ...

class SocialAuthView(APIView):
    # ... @transaction.atomic ...
    def post(self, request, provider):
        # ... validation and data extraction ...
        data = serializer.validated_data
        code = data.get('code')
        # --- PKCE Step 7 (Backend side): Extract code_verifier from frontend's request ---
        code_verifier = data.get('code_verifier') 
        # ... access_token, id_token ...

        # If code provided, exchange it
        if code and not access_token:
            try:
                if provider == 'google':
                    token_url = 'https://oauth2.googleapis.com/token'
                    payload = {
                        'code': code, 
                        'client_id': settings.GOOGLE_CLIENT_ID, 
                        'client_secret': settings.GOOGLE_CLIENT_SECRET, # Our backend uses its client_secret
                        'redirect_uri': settings.OAUTH_REDIRECT_URI, 
                        'grant_type': 'authorization_code',
                    }
                    # --- PKCE Step 8: Add code_verifier to the payload for Google ---
                    if code_verifier: payload['code_verifier'] = code_verifier 
                    r = requests.post(token_url, data = payload)
                    r.raise_for_status(); tok = r.json()
                    # ... get tokens ...

                # --- PKCE Step 8 (for LinkedIn): ---
                elif provider == 'linkedin':
                    token_url = 'https://www.linkedin.com/oauth/v2/accessToken'
                    payload = {
                        'grant_type': 'authorization_code',
                        'code': code,
                        'client_id': settings.LINKEDIN_CLIENT_ID,
                        'client_secret': settings.LINKEDIN_CLIENT_SECRET,
                        'redirect_uri': settings.OAUTH_REDIRECT_URI,
                    }
                    if code_verifier: payload['code_verifier'] = code_verifier
                    r = requests.post(token_url, data=payload)
                    r.raise_for_status(); access_token = r.json().get('access_token')

                # Similar logic for GitHub (already in place in the full code)
                # ... other providers ...

            except requests.RequestException as e:
                # ... error handling ...
        # ... rest of the social login logic ...
Enter fullscreen mode

Exit fullscreen mode



Which Social Providers Use PKCE?

Not all social login providers require or implement PKCE in the same way. It’s becoming more common for public clients.

Provider PKCE Support (in our project) Notes
Google Yes Highly recommended and used for SPAs.
GitHub Yes Supported and used for enhanced security.
LinkedIn Yes Supported and used.
Facebook No Often uses a simpler flow for web apps that doesn’t strictly require PKCE by default.

You can find a detailed explanation of PKCE (Proof Key for Code Exchange) Security here:

https://github.com/devesh111/Complete-User-Authentication/blob/main/05_pkce_proof_key_for_code_exchangesecurity.md




6. API Communication Layer (Frontend):

In our project, the frontend/src/api.js file is our lightweight API Communication Layer. It provides simple functions that abstract away the complexity of making fetch requests.



The api.js Messenger

// File: frontend/src/api.js

// This is the base URL for our backend API
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api";

// The core function that all requests go through
async function request(endpoint, options = {}) {
    // 1. Send the actual HTTP request to our backend
    const res = await fetch(API_URL + endpoint, {
        // Always include cookies with requests
        credentials: "include", 
        headers: {
            "Content-Type": "application/json", // We usually send and expect JSON
            ...(options.headers || {}), // Allow custom headers
        },
        ...options, // Other options like method (GET, POST), body
    });

    // 2. Check if the request was successful (HTTP status 200-299)
    if (!res.ok) {
        // If not successful, read the error message and throw an error
        const txt = await res.text();
        throw new Error(txt || "Request failed");
    }

    // 3. If successful, parse the response as JSON and return it
    return res.json();
}

// Simple helper functions for GET and POST requests
export default {
    get: (url, opts) => request(url, { method: "GET", ...opts }),
    post: (url, body, opts) =>
        request(url, { method: "POST", body: JSON.stringify(body), ...opts }),
};
Enter fullscreen mode

Exit fullscreen mode

Here’s how our LoginPage.jsx uses the api.post messenger function:

// File: frontend/src/pages/LoginPage.jsx (snippet)

import api from "../api"; // Import our API messenger
// ... other imports ...

export default function LoginPage() {
    // ... state setup ...

    const handleSubmit = async (e) => {
        e.preventDefault();
        setLoading(true);
        setMsg("");
        try {
            // This is it! We tell our API messenger to POST to /auth/login/
            // and pass it the form data. It handles all the HTTP details.
            const res = await api.post("/auth/login/", form);
            // If successful, 'res' now holds the JavaScript object
            // with access token, refresh token, and user data.
            dispatch(setAccess(res.access)); // Store access token
            dispatch(setUser(res.user));     // Store user info
        } catch (err) {
            setMsg("Invalid credentials"); // Handle any errors caught by api.js
        } finally {
            setLoading(false);
        }
    };

    // ... JSX ...
}

Enter fullscreen mode

Exit fullscreen mode

Registering a user is just as simple:

// File: frontend/src/pages/RegisterPage.jsx (snippet)

import api from "../api"; // Import our API messenger
// ... other imports ...

export default function RegisterPage() {
    // ... state setup ...

    const handleSubmit = async (e) => {
        e.preventDefault();
        setLoading(true);
        setMsg("");
        try {
            // Same pattern: use the api messenger for registration
            const res = await api.post("/auth/register/", form);
            setMsg(res.message || "Registered. Check email to verify.");
        } catch (err) {
            setMsg("Registration failed");
        } finally {
            setLoading(false);
        }
    };

    // ... JSX ...
}
Enter fullscreen mode

Exit fullscreen mode

While api.js is great for requests with JSON bodies, some endpoints in our backend don’t require a request body at all.



Example: Refreshing an Access Token

// File: frontend/src/store/authSlice.js (snippet)

export const refreshToken = createAsyncThunk("auth/refresh", async () => {
    const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
    // Direct fetch, primarily relying on 'credentials: "include"' for the cookie
    const res = await fetch(apiUrl + "/auth/token/refresh/", {
        method: "POST",
        credentials: "include", // Essential for sending the refresh_token cookie
    });
    // ... error handling and parsing ...
    const data = await res.json();
    return data.access;
});
Enter fullscreen mode

Exit fullscreen mode

Logging out is similar; it invalidates the refresh token (by deleting its cookie), which also doesn’t require a request body.

// File: frontend/src/store/authSlice.js (snippet)

export const logoutAsync = createAsyncThunk("auth/logout", async () => {
    const apiUrl = import.meta.env.VITE_API_URL || "http://localhost:8000/api";
    // Direct fetch to trigger cookie deletion on the backend
    await fetch(apiUrl + "/auth/logout/", {
        method: "POST",
        credentials: "include", // Ensures the cookie is sent, so backend can delete it
    });
    return true;
});

Enter fullscreen mode

Exit fullscreen mode

You can find a detailed explanation of API Communication Layer (Frontend) here:

https://github.com/devesh111/Complete-User-Authentication/blob/main/06_api_communication_layer_frontend_.md




7. Frontend Authentication State Management

The main problem this system solves is making a user’s authentication status globally available and consistent across our entire frontend application. We use Redux Toolkit – a popular state management framework – to act as our application’s reliable front desk manager.

  • A global Redux store, specifically an authSlice, acts as the single source of truth for our user’s authentication status.
  • createAsyncThunk actions (refreshToken, loginUser, checkAuth, logoutAsync) handle complex, asynchronous interactions with our backend.
  • useSelector allows any component to “ask” the manager for authentication information.
  • useDispatch allows components to “tell” the manager to perform authentication actions.
  • ProtectedRoute components use this state to intelligently control access to sensitive areas of our application, redirecting unauthenticated users to the login page.

This Redux-based system ensures that our frontend always knows the user’s login status, making our application responsive, secure, and user-friendly.

You can find a detailed explanation of Frontend Authentication State Management here:

https://github.com/devesh111/Complete-User-Authentication/blob/main/07_frontend_authentication_state_management_.md




Conclusion:

In this tutorial, we have set up a complete user authentication system for both traditional email/password and social logins using Django Rest Framework and ReactJS.



Code Repository

https://github.com/devesh111/Complete-User-Authentication/

Thanks for reading!
Devesh



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *