Authentication: Done the Right Way
Learn to apply authentication in your Full Stack App in the most secure way
Prologue
Authentication—a topic that brings joy to every developer's heart! Right up there with debugging and production outages. But, jokes aside, it’s crucial to get authentication right. Why? Because while you might enjoy seeing error logs, you definitely don’t want your app’s data or user accounts to go on a joyride in the wrong hands.
Introduction
Let’s talk about how to implement a secure authentication mechanism for a React frontend and Node.js backend without hiring security expert who charges by the hour. We’ll keep things focused on technical implementation, with some tips for security, and by the end, we’ll discuss why server-side rendering (SSR) with Next.js is your secret security weapon. Let's go!
First things first - Ditch that JWT in Bearer Tokens
You’ve probably heard all the buzz about JSON Web Tokens (JWT) being the cool kid on the block. They’re stateless, compact, and you can throw them into a localStorage
like candy. But let’s talk about why storing tokens in localStorage
or even sessionStorage
is a bit like leaving your car keys on the roof of your car and hoping no one takes them.
But what’s wrong with this approach
Essentially, to use Bearer Tokens, you need to store them somewhere in the browser. They’re usually stored in either Local Storages, Session Storages or sometimes in browser cookies.
Such tokens are vulnerable to XSS (Cross-Site Scripting) attacks. If some sneaky script gets into your app, it can access browser data and, voilà, steal your precious token. Now, that script can impersonate your user and wreak havoc.
Takeaway: Don’t store sensitive tokens in localStorage
or sessionStorage
. Just don’t.
Enter HTTPOnly Cookies
Instead of putting your JWT token on display like it’s a museum artifact, let’s hide it where prying eyes (and scripts) can’t reach—HttpOnly cookies.
HttpOnly cookies are sent from your server to the client and automatically included in every HTTP request to the same origin, but they can't be accessed via JavaScript (so no XSS fun for attackers).
Here’s why HttpOnly cookies are better
No XSS Risk: The cookie is invisible to JavaScript, so even if your app gets hit by an XSS attack, the token is safe.
Automatic Sending: Browsers automatically send cookies with every request to the backend, so no more manually attaching headers or playing “don’t forget the token!”
Expiration Control: You can set the
expires
andmax-age
attributes of cookies, which is less error-prone than trying to manage JWT expiry manually.HTTPS: Using HTTPS in production allows us to encrypt data in transit including cookies.
SameSite Attribute: Using the 'SameSite' attribute on cookies allows us to prevent CSRF attacks.
Sending a HTTPOnly Cookie via nodejs -
// Node.js: Send a secure HttpOnly cookie
res.cookie('token', jwtToken, {
httpOnly: true, // Makes it inaccessible to JS
secure: true, // Only send over HTTPS
sameSite: 'strict', // Helps prevent CSRF
maxAge: 3600000 // 1 hour
});
On the React side, you don’t need to worry about managing tokens. Just call your API and let cookies do their thing.
But Wait, What About CSRF?
Cross-Site Request Forgery (CSRF), the evil twin of XSS. When using cookies, CSRF attacks can trick users into making authenticated requests they didn’t intend.
One way to combat CSRF is by implementing CSRF tokens. These tokens are included in your frontend form or request and validated on the server. Essentially, it’s an extra check to make sure the request is legit.
So, cookies are great, but you still need to be mindful of CSRF and put measures in place to defend against it.
Implementing Authentication with React and Node.js
Let’s tie this all together and implement a simple authentication flow that keeps everything locked down. Here’s how we can make sure that everything between our React frontend and Node.js backend is airtight.
Step 1: Login Flow (Backend)
User enters their credentials (username, password) in a login form.
The credentials are sent to the backend API over HTTPS.
The server verifies the user credentials (with bcrypt hashed passwords, because we like security).
If the credentials are valid, the server generates a JWT token.
The JWT token is stored in an HttpOnly cookie.
// Node.js: Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await findUserByUsername(username);
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).send('Invalid credentials');
}
const jwtToken = createJwtToken(user.id);
res.cookie('token', jwtToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
}).send('Login successful');
});
Step 2: Authenticated Requests (Frontend)
Frontend makes a request to the backend to get some user data.
The browser automatically sends the HttpOnly cookie with the request.
The backend checks the token and, if valid, processes the request.
No manual header management. No token juggling. Just sweet, simple, secure authentication.
// React: Make an authenticated request
fetch('/api/user', {
method: 'GET',
credentials: 'include' // Makes sure cookies are sent
})
.then(response => response.json())
.then(data => {
console.log('User data:', data);
});
Implement Server Side Rendering - Even Better
Now, here’s where things get even more interesting (and secure). Server-side rendering (SSR) with Next.js gives you an added layer of protection.
With SSR, all rendering is done on the server, and you don’t have to expose sensitive user data or session tokens to the client-side at all. You can authenticate users on the server and render personalized pages before sending them to the browser.
How SSR Makes It More Secure
No Client-Side Token Storage: Since your backend does the rendering, you don’t need to store tokens on the client at all. This means no XSS or CSRF risks related to token storage.
Secure Data Fetching: Data fetching can be done securely on the server, and you don’t have to worry about exposing internal APIs or sensitive data to client-side JavaScript.
Reduced Attack Surface: By rendering pages on the server, you minimize the amount of client-side JavaScript, reducing the attack surface for XSS vulnerabilities.
// Next.js: SSR with authentication
export async function getServerSideProps(context) {
const token = context.req.cookies.token;
if (!token) {
return { redirect: { destination: '/login', permanent: false } };
}
// Verify token on the server
const user = await verifyJwtToken(token);
return {
props: { user }
};
}
Bonus Material
While we discussed a great deal of information on how to build a safe and secure authentication mechanism, there’s more to meet than we see -
1. OAuth 2.0 and OpenID Connect
For applications requiring third-party authentication or single sign-on (SSO) capabilities, OAuth 2.0 and OpenID Connect are excellent choices.
Benefits:
Standardized protocol
Supports various grant types for different use cases
Allows users to authenticate without sharing credentials with your application
Implementation:
Register your application with the OAuth provider
Implement OAuth flow (e.g., Authorization Code flow)
Exchange authorization code for access and refresh tokens
Use tokens to authenticate requests to your backend
2. Multi-Factor Authentication (MFA)
Regardless of the primary authentication method, implementing Multi-Factor Authentication significantly enhances security.
Types of MFA:
Time-based One-Time Passwords (TOTP)
SMS or email codes
Biometric authentication
Hardware tokens
Implementation:
After initial authentication, prompt for additional factor
Verify the additional factor before granting access
Consider allowing users to choose their preferred MFA method
Some additional tips -
Use HTTPS: Ensure all communication between the frontend and backend is encrypted using HTTPS.
Secure Storage: Store sensitive information (like JWTs) in HTTP-only cookies rather than local storage to prevent XSS attacks.
CSRF Protection: Implement CSRF tokens for forms and state-changing requests.
Rate Limiting: Implement rate limiting on your backend to prevent brute-force attacks.
Input Validation: Validate and sanitize all user inputs on both frontend and backend.
Secure Headers: Implement secure headers like Content-Security-Policy, X-XSS-Protection, and X-Frame-Options.
Password Hashing: Use strong, adaptive hashing algorithms like bcrypt or Argon2 for password storage.
Token Expiration: Implement short-lived access tokens and use refresh tokens for obtaining new access tokens.
Conclusion
So, if you want the most secure authentication between your React frontend and Node.js backend, using HttpOnly cookies is your best bet. They protect your tokens from XSS attacks, handle expiration better, and work seamlessly with SSR if you’re using Next.js.
And speaking of SSR, it adds a cherry on top by allowing you to handle authentication and data fetching on the server, making your app even more secure.
In short, store tokens in cookies, protect against CSRF, and, if possible, use SSR for the win! Now, go forth and secure your app like the rockstar you are!