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
- User and Account Data Models
- User Authentication Flow (Backend)
- Social Login Provider Integration
- Email Verification System
- PKCE (Proof Key for Code Exchange) Security
- API Communication Layer (Frontend)
- 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"]
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")
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)
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!"
}
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"
)
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)
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!"
}
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}
})
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
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")
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 <ACCESS_TOKEN>
)
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))
})
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
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/<str:provider>/ |
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) ...
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) ...
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) ...
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) ...
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
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.
The Backend's Social Login Endpoint: SocialAuthView
Our backend has a special API endpoint dedicated to handling social login requests:
- Endpoint: /api/auth/social/str:provider/
- Method: POST
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)
}
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
}
}
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)
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
)
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
)
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
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!"})
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;
}
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 ...
}
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 ...
}
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 ...
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 |
---|---|---|
Yes | Highly recommended and used for SPAs. | |
GitHub | Yes | Supported and used for enhanced security. |
Yes | Supported and used. | |
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:
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 }),
};
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 ...
}
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 ...
}
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;
});
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;
});
You can find a detailed explanation of API Communication Layer (Frontend) here:
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:
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
Top comments (0)