Auth[entic|oriz]ation
Securing access to our application involves two main things:
- Authentication: Verifying the identity of the user.
- Authorization: Verifying the user's permissions to access a resource.
Authentication
There are million of ways to verify the identity of the user these days including social login, email and password, etc. But once the authentication succeeds, the app should provide a way to make sure that the subsequent requests are coming from the authenticated user.
The most common way is by issuing a token and setting it as a secure same-site HTTP-only cookie in the response:
- secure: This ensures that the cookie is only sent to the server over HTTPS to protect against man-in-the-middle (MTITM) attacks.
- same-site: This ensures that the cookie is only sent to the server from the same site, which prevents cross-site request forgery (CSRF) attacks. Preferably set to
strictto prevent CSRF attacks. - HTTP-only: This ensures that the cookie is not accessible to JavaScript running in the browser, which prevents cross-site scripting (XSS) attacks.
This way, the token is stored in the browser and is sent to the server with each request. The server can then verify the token to authenticate the user.
Avoid local storage
Storing the token in the local storage is not a good idea because it can be accessed by JavaScript running in the browser, which can be a security risk.
Stateless vs Stateful
A stateless authentication is one where the server does not store any information about the user. The typical flow is:
- The server issues a token and the client stores it in the browser.
- The client then sends the token to the server with each request.
- During the authorization, the server only needs to verify the token to authenticate the user.
A stateful authentication is one where the server stores information about the user. The typical flow is:
- The server issues a token, relates it to the user, and stores it in the database.
- This token is then sent to the client and stored in the browser.
- The client then sends the token to the server with each request.
- During the authorization, the server needs to verify the token, retrieve the user from the database, and check if the user has the required permissions to access the resource.
Each method has its own advantages and disadvantages. The stateless authentication is more convenient and budget-friendly since it does not require any additional storage. However, it has a few disadvantages:
- The token is not stored in the database, so it is not possible to revoke it. Basically our system cannot force the user to re-login if the token is compromised.
- To solve the problem of token compromise, we need to implement a token refresh mechanism which adds complexity to the system. Or some people implement a token blacklist mechanism to revoke the token. But this approach makes the system become kind of stateful anyway.
The stateful authentication is more secure since the token is stored in the database, so it is possible to revoke it. However, it has a few disadvantages:
- it requires additional storage to store the token and user information in the database.
- it requires additional resource to retrieve the user information from the database.
- we still need to implement the token refresh mechanism anyway to avoid having long-lived token.
The decision to choose one method over the other depends on the requirements of the application. But generally, the stateful authentication the preferable choice since it is more secure. Even when token compromise is unlikely, the possibility of it not happening is not zero. So being able to revoke the token is a must thing to have.
Authorization
Authorization is the process of verifying the request is coming from an authenticated user with the required permissions to access the resource.
While the implementation details of authorization may vary depending on the application, the following should be followed.
getUser function
The getUser function is a helper function that can be used to get the user object from the request. For example,
import { cookies } from "next/headers";
import jwt from "jose";
import { cache } from "react";
export const getUser = cache(async () => {
const cookieStore = await cookies();
// Get the token from the cookie
const token = cookieStore.get("token")?.value;
if (!token) return null;
let decoded: jwt.JWTPayload;
try {
// Verify the token
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return null;
}
if (!decoded.sub) return null;
// Get the user from the database
const user = await db.user.findUnique({
where: {
id: decoded.sub,
},
});
return user;
});Then we can use this function in any server-side functions, including in React Server Components. For example, in the React Server Components:
const Page = async () => {
const user = await getUser();
if (!user) {
redirect("/login");
}
return (
<div>
<h1>Hello {user.name}</h1>
<Profile />
<Posts />
</div>
);
};const Profile = async () => {
const user = await getUser();
if (!user) {
return <div>Unauthorized</div>;
}
return <div>Profile of {user.name}</div>;
};const Posts = async () => {
const user = await getUser();
if (!user) {
return <div>Unauthorized</div>;
}
return <div>Posts of {user.name}</div>;
};
export default Posts;Good to know
In the example above, even though the getUser function is called three
times, it will only be executed once because of the
cache function.
Or in any server-side functions:
"use server";
import { getUser } from "./lib/user";
import { errorResponse } from "@hyperjumptech/route-next-gen/lib";
export const handler = async () => {
const user = await getUser();
if (!user) {
return errorResponse(401, "Unauthorized");
}
return successResponse(200, { message: "Hello {user?.name}" });
};Higher order functions
Repeating the same logic in multiple places can be a pain. To avoid this, we can create a higher order function that wraps the getUser function and returns a function that checks if the user is authenticated. For example,
import { getUser } from "./user";
export const withUser = (fn: (user: User) => Promise<Response>) => {
return async (request: Request) => {
const user = await getUser();
if (!user) {
return errorResponse(401, "Unauthorized");
}
return fn(user);
};
};Then we can use this function in any server-side functions:
import { withUser } from "./lib/user";
import { errorResponse } from "@hyperjumptech/route-next-gen/lib";
const handler = async (user: User) => {
return successResponse(200, { message: `Hello ${user.name}` });
};
export const handler = withUser(handler);And for the RSC, we can have a HOC like this:
import { withUser } from "./lib/user";
import { User } from "./user";
import { redirect } from "next/navigation";
export const withUser = (Component: React.ComponentType<{ user: User }>) => {
const WithUser = (props: T) => {
const user = await getUser();
if (!user) {
redirect("/login");
}
return <Component {...props} user={user} />;
};
return WithUser;
};Then we can use this function in any React Server Components:
const Profile = async ({ user }: { user: User }) => {
return (
<div>
<h1>Profile</h1>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
};
export default withUser(Profile);The key points to remember when using the HOC are so that the component:
- does not need to care how to get the user object
- is only rendered if the user is authenticated
This way the code is more modular, cleaner, and easier to understand.
Optimistic check
There are cases where a component only needs to check if the user is logged in. For example, a page that displays membership rules. If the user is not logged in, we can show a message to the user to login first. The page does not need to know the user object, it only needs to know if the user is logged in or not. So in this case there is no need to connect to the database to retrieve the user object.
For this kind of case, we can create a helper function that only checks the user optimistically like this:
import { withUser } from "./lib/user";
import { redirect } from "next/navigation";
const isLoggedIn = async () => {
const cookiesStore = await cookies();
const token = cookiesStore.get("token")?.value;
if (!token) return false;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return true;
} catch (error) {
return false;
}
};
export const withOptimisticCheck = (
Component: React.ComponentType<{ isLoggedIn: boolean }>
) => {
const WithOptimisticCheck = (props: T) => {
const isLoggedIn = await isLoggedIn();
if (!isLoggedIn) {
redirect("/login");
}
return <Component {...props} isLoggedIn={isLoggedIn} />;
};
return WithOptimisticCheck;
};As you can see, the isLoggedIn function only checks the token validity. It does not retrieve the user object from the database.
Then we can use this function in any React Server Components:
const Page = async ({ isLoggedIn }: { isLoggedIn: boolean }) => {
return (
<div>
<h1>Membership Rules</h1>
</div>
);
};
export default withOptimisticCheck(Page);Optimistic check is useful to keep our app secure but still fast and not using too much computational resources.
Avoid proxy.js
Proxy in Next.js, formerly called middleware, is a way to run code in the server before the request is completed. As mentioned in the official documentation, you may optionally run an optimistic check with a proxy.
However we must avoid using proxy when possible for the following reasons:
- Proxy is run on every request by default, including static files requests like images, fonts, etc. To solve this, we need to make sure to export the
configobject with the correctmatcheroption. - Even when you have configured the
matcheroption correctly, proxy is still run even on the prefetched routes. If a certain page renders multiple "Link" components, the proxy will be run multiple times because Next.js prefetches the routes. This will increase the cost of running the system especially in serverless environment. To solve this, we need to make sure the links are not prefetched by setting theprefetchoption tofalse. - Authorization is better to be performed as close as possible to the resource to make the code easier to reason about, to maintain, and to debug. Proxy "lives" outside of the component tree. In serverless environment like Vercel, the proxy even sometimes runs on CDN. Having authorization check in proxy will make it harder to reason about the authorization logic.
What to guard
The must
In Next.js with App router, we must guard the following:
- all server functions and server actions (files with
use serverdirective) that are not public end points. - all route handlers (
route.tsfiles) that are not public end points. - all page components (
page.tsxfiles) that are not public pages.
The should
And the following should be guarded for additional security:
- all RSC that should not be rendered if the user is not authenticated.
- all server-side functions that should not be executed if the user is not authenticated.
In both cases, we still should guard them even when the page.tsx and route.ts file that encapsulates them have been guarded to prevent accidental usage in unguarded page.tsx and route.ts files.
Ground Rules
| Action | |
|---|---|
| ✅ | Create getUser helper function to check if the user is authenticated and retrieve the user object from the database. |
| ✅ | Create isLoggedIn helper function to check if the user is authenticated optimistically. |
| ✅ | Create HOC to guard the server functions and RSC |