Search

User Authentication with FastAPI and Next.js

Aug 7, 2025

9 min read

FastAPINext.jsAuthJWTDDD
User Authentication with FastAPI and Next.js

This post covers a complete user authentication implementation using FastAPI for the backend and Next.js for the frontend. The system uses JWT tokens, bcrypt password hashing, and HTTP-only cookie management for secure session handling.

I’ll walk through the key components and explain the technical decisions behind each part.

Architecture Overview

The authentication system follows Domain-Driven Design (DDD) principles with clear separation between domain logic, application services, and infrastructure concerns:

  • Backend: FastAPI with PostgreSQL
  • Frontend: Next.js with React Query and Zustand
  • Security: JWT tokens with access/refresh token rotation
  • Session Management: Secure HTTP-only cookies

Backend Implementation

Domain Layer: User Entity and Value Objects

The User entity serves as the core domain object, encapsulating user data and behavior:

backend/src/aichat/domains/user/domain/entities/user.py
class User(Entity):
def __init__(
self,
*,
id: UUID,
created_at: dt.datetime,
updated_at: dt.datetime,
name: str,
email: str,
password: Password,
avatar_url: Optional[str] = None,
) -> None:
super().__init__(
id=id,
created_at=created_at,
updated_at=updated_at,
)
self.name = name
self.email = email
self.password = password
self.avatar_url = avatar_url
@classmethod
def create(
cls,
*,
name: str,
email: str,
password: Password,
avatar_url: Optional[str] = None,
) -> Self:
user = cls(
id=cls.generate_id(),
created_at=cls.get_current_datetime(),
updated_at=cls.get_current_datetime(),
name=name,
email=email,
password=password,
avatar_url=avatar_url,
)
return user
@property
def info(self) -> UserInfo:
return UserInfo(
user_id=self.id,
name=self.name,
email=self.email,
avatar_url=self.avatar_url,
)

The create class method handles ID generation and timestamp setting automatically. The info property returns user data without sensitive information like passwords, providing a safe way to share user details across the application.

Secure Password Handling

Password security uses a dedicated Password value object with bcrypt for hashing:

backend/src/aichat/domains/user/domain/value_objects/password.py
class Password(ValueObject):
hashed_value: str
@classmethod
def from_plaintext(cls, value: str) -> Self:
return cls(hashed_value=cls._hash(value))
def check(self, value: str) -> bool:
return bcrypt.checkpw(
value.encode(),
self.hashed_value.encode(),
)
@staticmethod
def _hash(value: str) -> str:
return bcrypt.hashpw(
value.encode(),
bcrypt.gensalt(),
).decode()

The from_plaintext method automatically hashes passwords with a unique salt via bcrypt.gensalt(). The check method performs secure password verification without exposing the original password. Bcrypt’s computational expense makes brute force attacks impractical.

Authentication Endpoints

The authentication API implements three main endpoints for the complete auth flow:

backend/src/aichat/presentation/rest/routers/auth.py
@router.post("/signup", response_model=SignupResponse)
@inject
def signup(
request: SignupRequest,
signup_command_handler: SignupCommandHandler = Depends(
Provide[AppDepContainer.signup_command_handler],
),
):
try:
signup_command = SignupCommand(
name=request.name,
email=request.email,
password=request.password,
)
signup_command_handler.handle(signup_command)
return SignupResponse(
status_code=status.HTTP_201_CREATED,
message="User registered successfully",
)
except Exception as e:
return SignupResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="Internal server error",
error=str(e),
)
@router.post("/login", response_model=LoginResponse)
@inject
def login(
response: Response,
request: LoginRequest,
login_command_handler: LoginCommandHandler = Depends(
Provide[AppDepContainer.login_command_handler],
),
):
try:
login_command = LoginCommand(
email=request.email,
password=request.password,
)
access_token, refresh_token = login_command_handler.handle(login_command)
# Set secure HTTP-only cookies
response.set_cookie(
key=ACCESS_TOKEN_COOKIE_KEY,
value=access_token,
httponly=True,
secure=True,
)
response.set_cookie(
key=REFRESH_TOKEN_COOKIE_KEY,
value=refresh_token,
httponly=True,
secure=True,
)
return LoginResponse(
status_code=status.HTTP_200_OK,
message="User logged in successfully",
)
except Exception as e:
return LoginResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
message="Internal server error",
error=str(e),
)

The response.set_cookie() calls instruct FastAPI to add Set-Cookie headers to the HTTP response. The browser receives these headers and stores the cookies with the specified security attributes.

Cookie settings breakdown:

  • httponly=True - Prevents JavaScript access, protecting against XSS attacks
  • secure=True - Ensures cookies only transmit over HTTPS connections
  • The browser automatically includes these cookies in subsequent requests

After successful login, the browser’s developer tools will show two cookies with token values, marked as HttpOnly and Secure:

Browser developer tools showing access-token and refresh-token cookies with HttpOnly and Secure flags set.

The complete authentication flow works as follows:

JWT Token Management

The login command handler generates both access and refresh tokens:

backend/src/aichat/domains/user/application/commands/login_command_handler.py
class LoginCommandHandler:
def handle(self, command: LoginCommand) -> tuple[str, str]:
with self._unit_of_work as unit_of_work:
# Find and validate user
user_repository: IUserRepository = unit_of_work.get_repository(IUserRepository)
user = user_repository.find_user_by_email(command.email)
if user is None:
raise ValueError(f"User with email {command.email} does not exist")
if not user.password.check(command.password):
raise ValueError("Invalid password")
# Generate tokens
access_token = self._token_service.create_access_token(
user_info=user.info,
)
refresh_token_value = self._token_service.create_refresh_token(
user_id=user.id,
)
# Store refresh token
refresh_token = RefreshToken.create(
user_id=user.id,
value=refresh_token_value,
)
refresh_token_repository: IRefreshTokenRepository = (
unit_of_work.get_repository(IRefreshTokenRepository)
)
refresh_token_repository.save_refresh_token(refresh_token)
return access_token, refresh_token_value

This design uses two token types: access tokens contain user information with short lifespans (15-30 minutes), while refresh tokens are random strings stored server-side with longer lifespans (7 days).

The dual-token approach balances security and usability. Stolen access tokens expire quickly, while refresh tokens enable seamless token renewal without frequent logins. Server-side refresh token storage allows for immediate revocation when needed.

Authentication Middleware

The authentication dependency extracts and validates tokens from protected route requests:

backend/src/aichat/presentation/rest/dependencies.py
@inject
def get_current_user_info(
request: Request,
token_service: ITokenService = Depends(Provide[AppDepContainer.token_service]),
) -> UserInfoDTO:
# Check Authorization header first
access_token = request.headers.get("Authorization")
if access_token is not None:
access_token = access_token.removeprefix("Bearer ")
# Fall back to cookies
else:
access_token = request.cookies.get(ACCESS_TOKEN_COOKIE_KEY)
if access_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
)
try:
# Verify token and extract user info
user_info = token_service.verify_access_token(access_token)
return UserInfoDTO(
user_id=user_info.user_id,
name=user_info.name,
email=user_info.email,
avatar_url=user_info.avatar_url,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Unauthorized: {e}",
)

This implementation supports dual authentication methods: Bearer tokens in Authorization headers (for API clients) and cookie-based authentication (for browsers). The fallback mechanism enables seamless compatibility across different client types.

Frontend Implementation

State Management with Zustand

The frontend uses Zustand for lightweight state management with minimal boilerplate:

frontend/src/stores/user-info-store.ts
interface State {
userInfo: UserInfo | null;
isSignedIn: boolean;
}
interface Action {
setUserInfo: (userInfo: UserInfo) => void;
setIsSignedIn: (isSignedIn: boolean) => void;
clearUserInfo: () => void;
}
const _useUserInfoStore = create<State & Action>()(
persist(
(set) => ({
userInfo: null,
isSignedIn: false,
setUserInfo: (userInfo) => set({ userInfo }),
setIsSignedIn: (isSignedIn) => set({ isSignedIn }),
clearUserInfo: () => set({ userInfo: null, isSignedIn: false }),
}),
{
name: "user-info-storage",
// Only persist the isSignedIn state for security
partialize: (state) => ({ isSignedIn: state.isSignedIn }),
},
),
);

The partialize configuration only persists the isSignedIn boolean to localStorage, excluding sensitive user data. When the app loads with isSignedIn: true, it fetches fresh user data from the server. This approach maintains persistent login state while avoiding security risks from storing sensitive data in localStorage.

API Integration with React Query

Custom hooks handle API integration with automatic data transformation and validation:

frontend/src/hooks/api/use-login.ts
export const useLogin = () => {
return useMutation({
mutationFn: async (loginData: LoginRequest): Promise<LoginResponse> => {
// Convert camelCase to snake_case for backend
const backendPayload = convertCamelToSnake(loginData);
const { data: backendResponse } = await apiClient.post(
"/api/auth/login",
backendPayload,
);
// Convert and validate response
const response: LoginResponse = LoginResponseSchema.parse(
convertSnakeToCamel(backendResponse),
);
return response;
},
});
};
export const useGetCurrentUserInfo = (options?: UseGetCurrentUserInfoOptions) => {
return useQuery({
queryKey: ["current-user-info"],
queryFn: async () => {
const { data: backendResponse } = await apiClient.get(
"/api/auth/current-user",
);
const response: GetCurrentUserInfoResponse =
GetCurrentUserInfoResponseSchema.parse(
convertSnakeToCamel(backendResponse),
);
return response.data;
},
enabled: options?.enabled ?? true,
});
};

The hooks automatically convert between camelCase (frontend) and snake_case (backend) conventions. Zod schemas validate API responses, catching unexpected data structures before they cause runtime errors.

Login Component

The login component demonstrates the integration between UI and authentication logic:

// frontend/src/app/(auth)/login/page.tsx
export default function LoginPage() {
const router = useRouter();
const setIsSignedIn = useUserInfoStore.use.setIsSignedIn();
const loginMutation = useLogin();
const handleLogin = async (data: LoginData) => {
try {
const response = await loginMutation.mutateAsync(data);
if (response.statusCode === 200) {
setIsSignedIn(true);
toast.success("Welcome back! You're now logged in.");
router.push("/");
} else {
const errorMessage = response.error || response.message || "Login failed. Please try again.";
toast.error(errorMessage);
}
} catch (error) {
console.error("Login error:", error);
toast.error("Something went wrong. Please check your connection and try again.");
}
};
return (
<div className="flex min-h-screen items-center justify-center px-4 py-12">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Welcome back
</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Sign in to your account to continue
</p>
</div>
<LoginForm onSubmit={handleLogin} isLoading={loginMutation.isPending} />
</div>
</div>
);
}

The handleLogin function manages the complete login flow: API calls, state updates on success, error handling, and user feedback. The loginMutation.isPending state automatically handles form loading states during submission.

Security Features

HTTP-Only Cookies

HTTP-only cookies provide XSS protection by preventing JavaScript access:

backend/src/aichat/presentation/rest/routers/auth.py
response.set_cookie(
key=ACCESS_TOKEN_COOKIE_KEY,
value=access_token,
httponly=True, # Prevents JavaScript access
secure=True, # HTTPS only
)

The httponly=True flag prevents malicious JavaScript from accessing authentication cookies, even during XSS attacks. The secure=True flag ensures cookies only transmit over HTTPS connections. Browser dev tools display these cookies with security flags indicating proper protection.

Password Security

  • Bcrypt hashing with automatic salt generation
  • Password validation happens in the domain layer
  • No plain text storage anywhere in the system

Token Management

  • Access tokens for short-term authentication
  • Refresh tokens stored server-side for revocation control
  • Dual authentication support (Bearer tokens + cookies)

Conclusion

This authentication system demonstrates several key techniques:

  • Secure password handling using bcrypt with automatic salt generation
  • JWT token management with access/refresh token separation for security and usability
  • Domain-driven architecture with clear separation of concerns
  • Flexible authentication supporting both API clients and web browsers
  • Type-safe integration with comprehensive error handling

The modular design supports future extensions like two-factor authentication, social login, or mobile app integration through existing Bearer token support.

Comments 💬