User Authentication with FastAPI and Next.js
Aug 7, 2025
9 min read

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:
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:
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:
@router.post("/signup", response_model=SignupResponse)@injectdef 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)@injectdef 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 attackssecure=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:
The complete authentication flow works as follows:
JWT Token Management
The login command handler generates both access and refresh tokens:
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:
@injectdef 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:
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:
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.tsxexport 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:
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 💬