Skip to main content

Building a React Login Form with JWT

A production-grade login form in React must balance security, usability, and feedback. It needs client-side validation to catch errors early, API integration to communicate with your backend, proper error messages, and visual feedback (loading state, disabled inputs) to prevent double-submission. This article builds a complete login form component with these patterns, paired with a backend endpoint that issues JWTs via httpOnly cookies.

Core Login Form Component

State Management and Form Handling

import React, { useState } from 'react';
import './LoginForm.css'; // Styling (see below)

function LoginForm({ onLoginSuccess }) {
// Form state
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

// UI state
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState({});

// Client-side validation
const validateForm = () => {
const errors = {};

// Email validation
if (!email.trim()) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Email format is invalid';
}

// Password validation
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}

setFieldErrors(errors);
return Object.keys(errors).length === 0;
};

// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();

// Clear previous error
setError('');

// Validate on client
if (!validateForm()) {
return;
}

setLoading(true);

try {
// POST to backend with credentials to send/receive httpOnly cookies
const response = await fetch('https://api.example.com/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password }),
credentials: 'include' // Send httpOnly cookies
});

const data = await response.json();

if (!response.ok) {
// Backend returned an error (401, 400, 500, etc.)
setError(data.error || 'Login failed');
return;
}

// ✅ Login successful
// The httpOnly cookie is automatically set by the browser
console.log('Logged in as:', data.user.email);

// Call parent callback or redirect
if (onLoginSuccess) {
onLoginSuccess(data.user);
} else {
window.location.href = '/dashboard';
}

} catch (err) {
// Network error or other exception
setError('Network error: ' + (err.message || 'Unknown'));
} finally {
setLoading(false);
}
};

// Handle input changes
const handleEmailChange = (e) => {
setEmail(e.target.value);
// Clear field error when user starts typing
if (fieldErrors.email) {
setFieldErrors({ ...fieldErrors, email: '' });
}
};

const handlePasswordChange = (e) => {
setPassword(e.target.value);
if (fieldErrors.password) {
setFieldErrors({ ...fieldErrors, password: '' });
}
};

return (
<div className="login-form-container">
<h2>Log In to Your Account</h2>

{error && <div className="alert alert-error">{error}</div>}

<form onSubmit={handleSubmit}>
{/* Email Field */}
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={handleEmailChange}
placeholder="[email protected]"
disabled={loading}
className={fieldErrors.email ? 'input-error' : ''}
autoComplete="email"
required
/>
{fieldErrors.email && (
<span className="error-message">{fieldErrors.email}</span>
)}
</div>

{/* Password Field */}
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={handlePasswordChange}
placeholder="••••••••"
disabled={loading}
className={fieldErrors.password ? 'input-error' : ''}
autoComplete="current-password"
required
/>
{fieldErrors.password && (
<span className="error-message">{fieldErrors.password}</span>
)}
</div>

{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="btn-primary"
>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>

{/* Links */}
<div className="form-footer">
<p>
Do not have an account?{' '}
<a href="/signup">Sign up here</a>
</p>
<p>
<a href="/forgot-password">Forgot password?</a>
</p>
</div>
</div>
);
}

export default LoginForm;

Styling with CSS

/* LoginForm.css */
.login-form-container {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fafafa;
}

.login-form-container h2 {
margin-top: 0;
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: #333;
}

.alert {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
font-size: 0.95rem;
}

.alert-error {
background-color: #fce4e4;
border: 1px solid #f5a6a6;
color: #d32f2f;
}

.form-group {
margin-bottom: 1.5rem;
}

.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #333;
font-size: 0.95rem;
}

.form-group input {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
transition: border-color 0.2s;
}

.form-group input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}

.form-group input:disabled {
background-color: #f0f0f0;
color: #999;
cursor: not-allowed;
}

.form-group input.input-error {
border-color: #d32f2f;
background-color: #fef5f5;
}

.error-message {
display: block;
margin-top: 0.25rem;
font-size: 0.85rem;
color: #d32f2f;
}

.btn-primary {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
color: white;
background-color: #0066cc;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}

.btn-primary:hover:not(:disabled) {
background-color: #0052a3;
}

.btn-primary:disabled {
background-color: #ccc;
cursor: not-allowed;
}

.form-footer {
margin-top: 1.5rem;
text-align: center;
font-size: 0.9rem;
}

.form-footer p {
margin: 0.5rem 0;
}

.form-footer a {
color: #0066cc;
text-decoration: none;
}

.form-footer a:hover {
text-decoration: underline;
}

Backend Endpoint: Issuing JWTs

Your backend must validate credentials and return a JWT via an httpOnly cookie:

// backend/routes/auth.js (Express.js)
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const router = express.Router();

const SECRET = process.env.JWT_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;

router.post('/login', express.json(), async (req, res) => {
const { email, password } = req.body;

// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}

try {
// Find user by email
const user = await User.findOne({ email });

if (!user) {
// ✅ Security: Do not reveal if email exists
return res.status(401).json({ error: 'Invalid email or password' });
}

// Compare password with hash
const passwordMatch = await bcrypt.compare(password, user.passwordHash);

if (!passwordMatch) {
return res.status(401).json({ error: 'Invalid email or password' });
}

// ✅ Credentials valid; create tokens
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role,
type: 'access'
},
SECRET,
{ expiresIn: '15m', issuer: 'auth.example.com' }
);

const refreshToken = jwt.sign(
{
sub: user.id,
type: 'refresh'
},
REFRESH_SECRET,
{ expiresIn: '7d', issuer: 'auth.example.com' }
);

// Set httpOnly cookies
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Strict',
maxAge: 15 * 60 * 1000,
path: '/'
});

res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/refresh'
});

// Return user info (not the token)
return res.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
}
});

} catch (err) {
console.error('Login error:', err);
return res.status(500).json({ error: 'Server error' });
}
});

module.exports = router;

Integration with React Context or State Management

For larger apps, you might wrap the login form in a context to manage global auth state:

// AuthContext.js
import React, { createContext, useState, useCallback } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

const login = useCallback(async (email, password) => {
setLoading(true);
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include'
});

if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Login failed');
}

const data = await response.json();
setUser(data.user);
return data.user;

} finally {
setLoading(false);
}
}, []);

const logout = useCallback(async () => {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
});
setUser(null);
}, []);

return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}

export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}

Then use it in your LoginForm:

import { useAuth } from './AuthContext';

function LoginForm() {
const { login } = useAuth();

const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
window.location.href = '/dashboard';
} catch (err) {
setError(err.message);
}
};

// ... rest of form
}

Key Takeaways

  • Always validate form input on the client (email format, password length) and display field-specific errors
  • Use loading state to disable inputs and show feedback during submission; prevents double-submission
  • Set credentials: 'include' in fetch to send httpOnly cookies with the login request
  • Never display overly specific error messages (e.g., "Email not found"); use generic messages to prevent user enumeration
  • The backend should issue access tokens (short-lived) and refresh tokens (long-lived) on successful login
  • Use httpOnly cookies to store tokens; React does not need to manage them manually
  • Add autoComplete attributes to email and password inputs for better UX
  • Log errors on the backend for monitoring; prevent sensitive details from reaching the client

Frequently Asked Questions

Why not use a state management library like Redux?

For simple login, context and hooks are sufficient. Redux adds complexity without benefit for auth state. Use Redux only if you have other global state that benefits from centralized management.

Should I hash passwords on the client before sending?

No. Always send the password over HTTPS to the backend. The backend hashes and verifies it. Client-side hashing does not add security (HTTPS already encrypts in transit) and complicates password reset workflows.

What if the backend rejects the request with a 500 error?

Display a user-friendly error message: "Something went wrong. Please try again later." Log the actual error on the backend for debugging. Never expose server stack traces to the client.

How do I implement "Remember Me" functionality?

Use a longer-lived refresh token cookie (e.g., 30 days instead of 7) when the user checks the "Remember Me" box. Set a flag in the JWT claim if needed. Longer-lived tokens increase exposure; balance convenience against security.

Can I use the login form with OAuth providers like Google?

Yes, but the flow differs: instead of a password, you exchange an OAuth code for tokens. This article covers the basic JWT flow; OAuth integration is covered in a later article.

Further Reading