DH
14 min read

Adding Next-Auth Authentication to Django, FastAPI, and Next.js Stack

Wire Next-Auth to FastAPI with JWT tokens. Get login, session refresh, and type-safe auth across your full-stack app—no enterprise complexity.

next-authfastapisecurity

This tutorial builds upon the Creating a Full Stack Application with Django, FastAPI, and Next.js guide. We'll add authentication by integrating Next-Auth (Auth.js v5 beta) into our existing full-stack application — wiring a Credentials provider in Next.js to a custom JWT login endpoint on the Django/FastAPI backend.

By the end you'll have:

  • A sign-in/sign-out flow protected by Next-Auth middleware
  • A Django FastAPI /users/login/ endpoint that issues short-lived JWTs
  • A /users/session/ endpoint that refreshes tokens transparently on every Next.js server-side render
  • Type-safe session data shared across the whole Next.js app

Why this architecture? Next-Auth stores the access token in an encrypted server-side JWT cookie. The session callback re-validates and silently rotates the token on every request, so your users never hit a mid-session 401 without being shown an error page.


1. Initial Setup

If you haven't completed the first tutorial, start by cloning the repository and setting up the initial project.

1.1 Clone the Repository

git clone [email protected]:damianhodgkiss/next-django-fastapi-fullstack-tutorial.git

1.2 Start the Docker Stack

docker compose up --build -d

1.3 Run Database Migrations

docker compose exec api python manage.py migrate

1.4 Create a Superuser

docker compose exec api python manage.py createsuperuser

Follow the prompts to create your superuser account. Use an email address as the username — the Django backend authenticates by email.


2. Install and Configure Next-Auth

2.1 Install Next-Auth

We install the @beta tag because Auth.js v5 (the App Router-native version) is still in beta. It ships the auth() helper used in Server Components and the new NextAuth() initialiser.

docker compose exec frontend yarn add next-auth@beta

2.2 Create Next-Auth Configuration

Create src/auth.ts. This is the single source of truth for all Next-Auth options:

import NextAuth from "next-auth";

export const { auth, handlers, signIn, signOut } = NextAuth({
secret: process.env.NEXTAUTH_SECRET || "secret",
providers: [],
});

Why a separate auth.ts? Auth.js v5 exports named helpers (auth, handlers, signIn, signOut) from the single initialisation call. Putting them in one file lets you import auth into any Server Component without re-instantiating the library.

2.3 Set Up Next-Auth API Routes

Create src/app/api/auth/[...nextauth]/route.ts. Auth.js v5 uses a catch-all route to handle sign-in, sign-out, CSRF, and callback traffic:

import { handlers } from "@/auth";

export const { GET, POST } = handlers;

2.4 Add Next-Auth Middleware

Edit src/middleware.ts. The middleware runs on every matched request and redirects unauthenticated users before the page ever renders:

export { auth as middleware } from "@/auth";

export const config = {
// Protect every route except the auth routes and static assets
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"],
};

Edge case: Without the matcher exclusion, Next-Auth middleware intercepts its own /api/auth/* callbacks, causing an infinite redirect loop. Always exclude api/auth from the matcher.


3. Create Sign In and Sign Out Components

3.1 Create Sign In Button Component

Create src/app/components/sign-in.tsx:

"use client";

import { signIn } from "next-auth/react";

export function SignInButton() {
return (
<button
className="bg-blue-500 py-2 px-4 rounded text-white"
onClick={() => signIn()}
>
Sign in
</button>
);
}

3.2 Create Sign Out Button Component

Create src/app/components/sign-out.tsx:

"use client";

import { signOut } from "next-auth/react";

export function SignOutButton() {
return (
<button
className="bg-blue-500 py-2 px-4 rounded text-white"
onClick={() => signOut()}
>
Sign out
</button>
);
}

Why "use client"? signIn() and signOut() from next-auth/react rely on browser APIs and React context. Server Components must use the signIn/signOut helpers exported from src/auth.ts (via a Server Action) instead.

3.3 Update Home Page

Edit src/app/page.tsx:

import { auth } from "@/auth";
import { SignInButton } from "./components/sign-in";
import { SignOutButton } from "./components/sign-out";

export default async function Home() {
const session = await auth();
const { user } = session || {};
const isSignedIn = !!user;

return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
{isSignedIn ? (
<div>
<pre>{JSON.stringify(session, null, 2)}</pre>
<SignOutButton />
</div>
) : (
<SignInButton />
)}
</main>
);
}

4. Configure Next-Auth for Django Credentials

4.1 Set Environment Variables

Create frontend/.env.local:

NEXTAUTH_SECRET=secret
INTERNAL_API_URL=http://api:8000
  • NEXTAUTH_SECRET must be a long random string in production. Generate one with openssl rand -base64 32.
  • INTERNAL_API_URL uses the Docker Compose service name (api) so the Next.js container talks directly to Django inside the Docker network — bypassing Nginx and avoiding an extra round-trip.

4.2 Create Next-Auth Type Declarations

Create frontend/src/types/next-auth.d.ts to extend Auth.js's built-in types with your Django user fields:

import NextAuth, { DefaultSession } from "next-auth";

declare module "next-auth" {
interface User {
first_name: string;
last_name?: string;
is_staff: boolean;
is_active: boolean;
is_superuser: boolean;
last_login: string;
date_joined: string;
}

interface Session extends DefaultSession {
user: User;
access_token: string;
token_type: "Bearer";
}
}

declare module "next-auth/jwt" {
interface JWT {
user: User;
access_token: string;
token_type: "Bearer";
}
}

Why extend DefaultSession? Auth.js ships a minimal Session type ({ user?: { name, email, image }, expires }). Extending it — rather than replacing it — preserves the built-in expires field and keeps TypeScript happy across the whole app.

4.3 Update Next-Auth Configuration

Update frontend/src/auth.ts with the Credentials provider and the two key callbacks:

import NextAuth, { CredentialsSignin } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

export const { auth, handlers, signIn, signOut } = NextAuth({
secret: process.env.NEXTAUTH_SECRET || "secret",
providers: [
CredentialsProvider({
id: "django",
name: "Django",
credentials: {
username: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const response = await fetch(
`${process.env.INTERNAL_API_URL}/users/login/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
}
);
const json = await response.json();

if (!response.ok) throw new CredentialsSignin(json.detail);

return json;
},
}),
],
callbacks: {
/**
* jwt callback — runs when a token is created or updated.
*
* On first sign-in, `account.provider === 'django'` so we copy the
* access_token and user from the `authorize` response into the JWT.
* On subsequent calls (page refreshes, token checks) `account` is
* undefined — we leave the token untouched so the session callback
* can decide whether to rotate it.
*/
async jwt({ token, user, account }) {
switch (account?.provider) {
case "django": {
const credentialUser = user as any;
token.access_token = credentialUser?.access_token;
token.token_type = credentialUser?.token_type;
token.user = credentialUser?.user;
break;
}
}
return token;
},

/**
* session callback — runs on every `auth()` / `getServerSession()` call.
*
* We hit the Django `/users/session/` endpoint here for two reasons:
* 1. Validate that the stored access_token is still accepted by the API.
* 2. Silently rotate the token when it is close to expiry (the
* ACCESS_TOKEN_VALID_MINUTES window on the Django side controls this).
*
* Returning `{ expires: new Date().toISOString() }` immediately expires
* the Next-Auth session, which forces a sign-out on the next request.
*/
async session({ session, token }) {
const accessToken = token?.access_token;
const expireSession = {
expires: new Date().toISOString(),
};

if (!accessToken) {
return expireSession;
}

const response = await fetch(
`${process.env.INTERNAL_API_URL}/users/session/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
}
);

if (response.status !== 200) {
return expireSession;
}

const json = await response.json();

return {
...session,
access_token: json.access_token,
token_type: json.token_type,
user: json.user,
};
},
},
});

Why call the API inside the session callback and not the jwt callback?

The jwt callback runs even when no session is needed (e.g. middleware token checks). The session callback only runs when a component actually reads the session, reducing unnecessary backend calls. It also means every rendered page gets a fresh token if the old one is about to expire — no user ever sees a stale access_token in their browser.

Common failure modes:

SymptomLikely causeFix
Redirect loop on sign-inNEXTAUTH_SECRET missing or mismatched between restartsSet a stable secret in .env.local
401 on every page loadINTERNAL_API_URL resolves to localhost inside DockerUse the Docker service name (http://api:8000)
Session immediately expires after sign-inauthorize response shape doesn't match what jwt callback readsLog user in the jwt callback and compare to the /users/login/ JSON
TypeScript errors on session.access_tokenType declarations not picked upRestart the TS server; ensure tsconfig.json includes src/types

4.4 Update Docker Compose Configuration

Edit docker-compose.yml to inject the env file into the frontend service:

frontend:
env_file:
- ./frontend/.env.local

4.5 Restart Frontend Container

docker compose up frontend -d

5. Create FastAPI Routes for Django Authentication

5.1 Update Backend Requirements

Edit backend/requirements.txt to add JWT support. We use python-jose (latest stable: 3.5.0) with the cryptography extra for full algorithm support:

python-jose[cryptography]==3.3.0

Note: Pin to 3.3.0 if your project requires it, or upgrade to the latest 3.5.0. The [cryptography] extra replaces the older pycrypto backend and is the recommended install for new projects.

5.2 Create User Schemas

Create backend/users/schemas.py:

from pydantic import BaseModel
from pydantic import ConfigDict
from typing import Optional
from datetime import datetime

class LoginRequest(BaseModel):
username: str
password: str

class UserSchema(BaseModel):
id: int
email: str
first_name: Optional[str] = None
last_name: Optional[str] = None
name: Optional[str] = None
is_staff: bool
is_active: bool
is_superuser: bool
last_login: datetime | None
date_joined: datetime

model_config = ConfigDict(from_attributes=True)

class Token(BaseModel):
access_token: str
token_type: str
user: UserSchema

5.3 Create Authentication Utilities

Create backend/users/utils.py:

from fastapi import HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from django.conf import settings
from django.contrib.auth import get_user_model
from typing import Any, Annotated
from .models import User

SECRET_KEY = settings.SECRET_KEY
ALGORITHM = "HS256"
# Token lives 30 days in the cookie; the session endpoint decides when to issue a fresh one
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 30
# Tokens with less than this many minutes left get rotated by /users/session/
ACCESS_TOKEN_VALID_MINUTES = 1

security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return token

async def get_access_token(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
) -> str:
if not credentials:
raise credentials_exception
try:
return credentials.credentials
except JWTError:
raise credentials_exception

async def get_token_payload(
token: Annotated[str, Depends(get_access_token)]
) -> dict[str, Any]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
id: str | None = payload.get("sub")
if id is None:
raise credentials_exception
return payload
except JWTError:
raise credentials_exception

async def get_current_user(
payload: Annotated[dict[str, Any], Depends(get_token_payload)]
) -> User:
try:
id: str | None = payload.get("sub")
if id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = await get_user_model().objects.filter(id=id, is_active=True).afirst()
if user is None:
raise credentials_exception
return user

async def get_optional_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(optional_security)]
) -> User | None:
if not credentials:
return None
try:
payload = await get_token_payload(credentials.credentials)
return await get_current_user(payload)
except HTTPException:
return None

async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
) -> User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

Why HS256 and Django's SECRET_KEY?
Using Django's existing SECRET_KEY means you don't need to manage a separate signing secret. HS256 is a symmetric algorithm — the same key signs and verifies, which is fine for a single-service backend. If you ever split auth into a microservice, switch to RS256 and a dedicated key pair.

5.4 Create Authentication Routers

Create backend/users/routers.py:

from fastapi import FastAPI, APIRouter, Depends
from django.contrib.auth import authenticate
from typing import Annotated, Any
from datetime import timedelta, datetime
from .schemas import LoginRequest, Token
from .utils import (
create_access_token,
get_access_token,
get_token_payload,
get_current_active_user,
credentials_exception,
ACCESS_TOKEN_EXPIRE_MINUTES,
ACCESS_TOKEN_VALID_MINUTES,
)
from .models import User

router = APIRouter(prefix="/users", tags=["users"])

@router.post("/login/")
def login(login: LoginRequest):
"""
Authenticate with Django's built-in `authenticate()`.
Returns a long-lived JWT plus the full user object.
The long expiry (30 days) is intentional: the session callback
on the Next.js side re-validates every request, so even a
"valid" JWT is rejected if the user has been deactivated.
"""
user = authenticate(email=login.username, password=login.password)
if not user:
raise credentials_exception

access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": str(user.id)}, expires_delta=access_token_expires
)

return Token(access_token=access_token, token_type="Bearer", user=user)


@router.post("/session/")
async def check_session(
token: Annotated[str, Depends(get_access_token)],
payload: Annotated[dict[str, Any], Depends(get_token_payload)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> Token:
"""
Called by the Next-Auth session callback on every server-side render.
- If the token still has more than ACCESS_TOKEN_VALID_MINUTES remaining,
return it as-is (no unnecessary rotation).
- Otherwise, issue a fresh token with the full expiry window.
This keeps the user logged in indefinitely as long as they keep
visiting, while still respecting deactivation (get_current_active_user
raises 401 for inactive accounts).
"""
exp_time = datetime.fromtimestamp(payload["exp"])
current_time = datetime.now()
time_difference = exp_time - current_time
difference_in_minutes = time_difference.total_seconds() / 60

if difference_in_minutes >= ACCESS_TOKEN_VALID_MINUTES:
return Token(access_token=token, token_type="Bearer", user=current_user)

access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": str(current_user.id)}, expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer", user=current_user)


def register_routers(app: FastAPI):
app.include_router(router)

5.5 Update ASGI Configuration

Update backend/mysaas/asgi.py to register the users router with FastAPI:

import os

from django.core.asgi import get_asgi_application
from fastapi import FastAPI

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysaas.settings")

application = get_asgi_application()
fastapp = FastAPI(
servers=[
{
"url": "/api/v1",
"description": "V1",
}
]
)

def init(app: FastAPI):
from users.routers import register_routers as register_user_routers

register_user_routers(app)

@app.get("/health")
def health_check():
return {"status": "ok"}

init(fastapp)

6. Verify the Setup

6.1 Rebuild the Backend

After adding python-jose to requirements.txt, rebuild the API container:

docker compose up api --build -d

6.2 Check the FastAPI Health Endpoint

Confirm FastAPI is responding before testing auth:

curl http://localhost:8000/health
# {"status":"ok"}

6.3 Test the Login Endpoint Directly

Use the superuser credentials you created in step 1.4:

curl -X POST http://localhost:8000/users/login/ \
-H "Content-Type: application/json" \
-d '{"username": "[email protected]", "password": "yourpassword"}'

A successful response looks like:

{
"access_token": "eyJ...",
"token_type": "Bearer",
"user": {
"id": 1,
"email": "[email protected]",
"first_name": "",
"is_staff": true,
"is_active": true,
"is_superuser": true,
...
}
}

6.4 Test the Sign-In Flow in the Browser

  1. Navigate to http://localhost:3000.
  2. Click Sign in — you should be taken to the Next-Auth built-in credentials form.
  3. Enter the superuser email and password.
  4. On success, the home page should display the full session JSON and a Sign out button.

6.5 Verify Token Rotation

Open the Network tab in DevTools and watch requests to /api/auth/session. After you sign in, each hard-reload calls the Next-Auth session callback, which in turn calls /users/session/ on Django. If the token is still valid you'll see the same access_token echoed back; when it reaches the ACCESS_TOKEN_VALID_MINUTES threshold a fresh token is returned.


7. Common Issues & Troubleshooting

ProblemCauseFix
CredentialsSignin error on the formWrong email/password, or Django's authenticate() isn't finding the userVerify the superuser was created with an email, not a username
fetch failed in the session callbackINTERNAL_API_URL is not reachable from the Next.js containerConfirm the api service name in docker-compose.yml matches INTERNAL_API_URL
Infinite redirect after sign-inMiddleware matcher catching the auth callback routeEnsure api/auth is excluded from the matcher in middleware.ts
JWTError / jose not foundpython-jose not installed in the API containerRun docker compose up api --build -d to rebuild
TypeScript: Property 'access_token' does not exist on type 'Session'Module augmentation not appliedCheck tsconfig.json includes "include": ["src"] and restart TS server
Session expires immediately after sign-inauthorize response shape mismatchAdd console.log(user, account) in the jwt callback to inspect payloads
Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps