Step-by-Step Guide to JWT Authentication in React
What is Authentication
Authentication is crucial in a React application for ensuring that users are who they claim to be and have the appropriate access to resources. Proper authentication mechanisms protect sensitive data, maintain user privacy, and prevent unauthorized access. Here are a few reasons why authentication is important:
Security: It prevents unauthorized users from accessing sensitive data and functionality.
User Experience: Proper authentication ensures personalized and secure user experiences.
Regulatory Compliance: Many industries have regulations that require strong authentication measures to protect data.
Recent Vulnerabilities
GitHub OAuth Token Breach (2022):
- Attackers used stolen OAuth tokens from Heroku and Travis CI to access private repositories on GitHub. This breach impacted dozens of organizations and highlighted the risks associated with OAuth tokens when not properly secured.
Threatpost. (BleepingComputer) (BleepingComputer).
- Attackers used stolen OAuth tokens from Heroku and Travis CI to access private repositories on GitHub. This breach impacted dozens of organizations and highlighted the risks associated with OAuth tokens when not properly secured.
Microsoft OAuth Flaw (2021):
- An OAuth vulnerability allowed attackers to take over Azure accounts by exploiting authentication issues in applications like Portfolios and O365 Secure Score. The attack involved registering a malicious app to intercept an OAuth token through phishing. (Threatpost).
Mitigation Strategies
Implement Strong Authentication: Use multi-factor authentication (MFA) to add an extra layer of security.
Secure Token Storage: Store tokens securely (e.g., in HTTP-only cookies) to prevent theft via XSS attacks.
Regular Audits and Penetration Testing: Conduct regular security audits and penetration testing to identify and fix vulnerabilities.
Use Secure Password Policies: Enforce strong password policies, including the use of complex passwords and regular updates.
Basic React Application Flow
Before fulfilling requests, the server needs to ascertain the user's identity. Since there is no direct contact between the server and the user application, users can only interact with the server through the UI. Therefore, mechanisms must be implemented on both the UI and server sides to validate the user. For each request, the server must determine if it originates from a valid user or not. To all this work securely we use JWT token
JWT Token
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
Types of JWT Token
Access Token
Refresh Token
used either by the server or the React application to authenticate the user and then allow the user to make requests to the server.
Authentication Flow
The user sends requests to the server for authentication.
The server verifies the user's email and password against the database.
The user signs in with an email and password.
If the user is valid, the server generates a refresh token.
Refresh token is stored in an HTTP-only cookie by the server.
The server generates an access token containing user data or the refresh token itself.
The access token is sent to the React application.
React application manages and stores the access token.
Access Token is crucial for authenticating user requests to the server.
Tokens can have expiration dates for security purposes.
For instance, setting a 15-minute expiration time on the access token.
Where to store the access token
Common storage places for tokens include local storage or cookies, but these can be accessed by hackers. The safest option is to store tokens in the application state, within the computer memory that runs the process.
However, if the user refreshes the application state or if the access token expires, this poses a challenge. In such cases, the server may receive an undefined or expired token, resulting in failed user validation on the server side. Instead of immediately logging out the user, the server will first check if there is a refresh token available. If a valid refresh token exists, the server will generate a new access token and send it with the response to the React application.
Implementation
AuthProvider.jsx
const AuthProvider = ({childre}) => {
const [token , setToken] = useState();
useEffect(() => {
const fetchMe = async () => {
try {
const response = await api.get('/api/me');
setToken(response.data.accessToken);
}catch{
setToken(null);
}
}
fetchMe();
}, [])
}
This is authProvider function that will handle the acquiring of the access token when the application mounts
const [token , setToken] = useState();
State that will hold the access token
const fetchMe = async () => {
try {
const response = await api.get('/api/me');
setToken(response.data.accessToken);
}catch{
setToken(null);
}
}
fetchMe();
this function is essentially try to fetch the access token from the API ('/api/me') and it will get the response.data.accessToken and then send that token in the state, this state will be consumed by the entire application.
if the user is not authenticated we will set the token as null and this will be used to determine by the application if the user is authenticated or not.
//AuthProvider.jsx
useLayoutEffect(() => {
const authInterceptor = api.interceptors.request.use((coonfig) => {
config.headers.Authorization =
!config._retry && token
? `Bearer ${token}`
: config.headers.Authorization;
return config;
});
return () => {
api.interceptors.request.eject(authInterceptor);
};
}, [token])
In this code, we basically capture all of the requests which are going out of the application and inject the token into those request headers.It will run whenever the
useLayoutEffect
Hook:
useLayoutEffect
is similar to useEffect
, but it fires synchronously after all DOM mutations. This ensures that the effect runs before the browser paints, which can be useful for reading layout information or applying style changes.
Interceptor Setup:
const authInterceptor = api.interceptors.request.use((config) => {
config.headers.Authorization =
!config._retry && token
? `Bearer ${token}`
: config.headers.Authorization;
return config;
});
An interceptor is added to the
api
instance's request pipeline.For each request, the interceptor checks if the request is not a retry (
!config._retry
) and if atoken
is available.If both conditions are met, it sets the
Authorization
header toBearer ${token}
.If the conditions are not met, it leaves the
Authorization
header unchanged.
Cleanup Function:
return () => {
api.interceptors.request.eject(authInterceptor);
};
This cleanup function runs when the component unmounts or before the effect re-runs due to changes in dependencies (in this case, the
token
).It removes (ejects) the interceptor from the
api
instance, preventing memory leaks or unwanted behavior.
Wrapper for Axios MOC adapter that adds authentication checks
export const withAuth =
(...data) =>
async (config) => {
const token = config.headers.Authorization?.split(' ')[1];
const verified = token ? await verifyToken(token) : false;
if(env.USE_AUTH && !verified){
return [403, {message:'Unauthorized'}];
}
return typeof data[0] === 'function' ? data[0](config) : data;
}
This function essentially wraps all the Axios requests, and it will check if we have a valid token in the Authorization Header or not
//verifyToken
export const verifyToken = async(token, options = undefined) => {
try {
const verification = await jose.jwtVerify(token, jwtSecret);
return options?.returnPayload ? verification.payload : true
}catch {
return false;
}
}
This Function verifies the token that's passed by the With Auth wrapper above, it uses Jose package to decode and validate the jwt token.
Optional
How can we use withAuth
to wrap your API request handlers:
javascriptCopy codeimport api from './api';
import { withAuth } from './withAuth';
const fetchProtectedData = withAuth(async (config) => {
const response = await api.get('/protected-endpoint', config);
return response.data;
});
Handling the case where the Token is expired, or the user has refreshed the application state
useLayoutEffect(() => {
const refreshInterceptor = api.interceptors.response.use(
async (error) => {
if (error.response.status === 403 && error.response.data.message === 'unauthorized') {
try {
const response = await api.get('/api/refreshToken');
setToken(response.data.accessToken);
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
originalRequest._retry = true;
return api(originalRequest);
} catch {
setToken(null);
}
}
return Promise.reject(error);
}
);
return () => {
api.interceptors.response.eject(refreshInterceptor);
};
}, []);
Interceptor Setup:
const refreshInterceptor = api.interceptors.response.use(
async (error) => {
if (error.response.status === 403 && error.response.data.message === 'unauthorized') {
try {
const response = await api.get('/api/refreshToken');
setToken(response.data.accessToken);
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
originalRequest._retry = true;
return api(originalRequest);
} catch {
setToken(null);
}
}
return Promise.reject(error);
}
);
This interceptor is set up to handle response errors.
When a response error occurs, it checks if the status is 403 and the message is "unauthorized".
If so, it attempts to refresh the token by calling the
/api/refreshToken
endpoint.On successful token refresh, it updates the
Authorization
header of the original request with the new token and retries the request.If refreshing the token fails, it sets the token to
null
.
Token Refresh Logic:
const response = await api.get('/api/refreshToken');
setToken(response.data.accessToken);
originalRequest.headers.Authorization = `Bearer ${response.data.accessToken}`;
originalRequest._retry = true;
return api(originalRequest);
This logic performs the token refresh and retries the original request with the new token.
Error Handling:
} catch {
setToken(null);
}
return Promise.reject(error);
Ejecting the Interceptor:
return () => {
api.interceptors.response.eject(refreshInterceptor);
};
Conclusion
This is how you can handle the authentication of the users securely.
Authentication is essential to the application, making it more reliable and trustworthy.
JWT authentication is one of the common ways of implementing the authentication
JWT means JSON web token, which are encrypted strings containing confidential information about the user
The new user server generates the refresh token and stores it in a secured HTTP cookie.
The server never sends the refresh token to the application
we can set the expiry of the access Token based on which server will verify the incoming requests
The server generates the Access Token and sends it to the application UI
access Token should be stored in the application state, it's more secure.