Tutorial
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.
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
sessioncallback 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 git@github.com: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 importauthinto 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
matcherexclusion, Next-Auth middleware intercepts its own/api/auth/*callbacks, causing an infinite redirect loop. Always excludeapi/authfrom 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()andsignOut()fromnext-auth/reactrely on browser APIs and React context. Server Components must use thesignIn/signOuthelpers exported fromsrc/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_SECRETmust be a long random string in production. Generate one withopenssl rand -base64 32.INTERNAL_API_URLuses 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 minimalSessiontype ({ user?: { name, email, image }, expires }). Extending it — rather than replacing it — preserves the built-inexpiresfield 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:
| Symptom | Likely cause | Fix |
|---|---|---|
| Redirect loop on sign-in | NEXTAUTH_SECRET missing or mismatched between restarts | Set a stable secret in .env.local |
| 401 on every page load | INTERNAL_API_URL resolves to localhost inside Docker | Use the Docker service name (http://api:8000) |
| Session immediately expires after sign-in | authorize response shape doesn’t match what jwt callback reads | Log user in the jwt callback and compare to the /users/login/ JSON |
TypeScript errors on session.access_token | Type declarations not picked up | Restart 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.0if your project requires it, or upgrade to the latest3.5.0. The[cryptography]extra replaces the olderpycryptobackend 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": "admin@example.com", "password": "yourpassword"}'
A successful response looks like:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"user": {
"id": 1,
"email": "admin@example.com",
"first_name": "",
"is_staff": true,
"is_active": true,
"is_superuser": true,
...
}
}
6.4 Test the Sign-In Flow in the Browser
- Navigate to
http://localhost:3000. - Click Sign in — you should be taken to the Next-Auth built-in credentials form.
- Enter the superuser email and password.
- 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
| Problem | Cause | Fix |
|---|---|---|
CredentialsSignin error on the form | Wrong email/password, or Django’s authenticate() isn’t finding the user | Verify the superuser was created with an email, not a username |
fetch failed in the session callback | INTERNAL_API_URL is not reachable from the Next.js container | Confirm the api service name in docker-compose.yml matches INTERNAL_API_URL |
| Infinite redirect after sign-in | Middleware matcher catching the auth callback route | Ensure api/auth is excluded from the matcher in middleware.ts |
JWTError / jose not found | python-jose not installed in the API container | Run docker compose up api --build -d to rebuild |
TypeScript: Property 'access_token' does not exist on type 'Session' | Module augmentation not applied | Check tsconfig.json includes "include": ["src"] and restart TS server |
| Session expires immediately after sign-in | authorize response shape mismatch | Add console.log(user, account) in the jwt callback to inspect payloads |