OIDC in Next.js App Router with Lucia

by Lorenz Bösch

July 2024

Update October 2024: Lucia will get deprecated by March 2025 😱 While we could not recommend to build something new with this library, the concepts and patterns are still valid. Instead of using something else, it makes sense to handle session stuff by yourself in combination with arctic or oslo and use the patterns offered by lucia.


Who is this lucia, your talking about?!

This blog post dives into OIDC and session handling for a Next.js App Router application. I’ll show you why it’s a great idea to use Lucia with a persistent session storage like Redis.

For those not familiar with OIDC, check out this blog post about the essentials of OIDC.

But, who is Lucia?!?! Lucia is a small yet powerful auth library that helps you handle sessions and basic OIDC flows with ease. And guess what? It has some pretty awesome documentation. And here’s the best part—it already has examples for integrating with a Next.js App Router!

Lucia Auth DocumentationApp Router Example on Github

So, why another blog post, you might ask?

While Lucia’s documentation is excellent, this post goes beyond the basics. We'll cover

  • Refreshing access tokens using refresh tokens
  • Session Handling for Client Components

By diving into these more advanced topics, you’ll be able to implement a robust, real-world authentication solution tailored to your Next.js application.

PS: We won't cover the basic lucia and oslo setup, I hope you've read and understood the documentation.

Refresh Tokens

With the App Router, more and more logic is being moved back to the server. This shift allows for better control over server-side rendering and session management, but it also means that having a reliable server-side session is essential.

From a UX perspective, it’s crucial that a user’s session remains active for as long as possible. If a session expires too quickly, users may experience annoying flicker effects, where the UI briefly shows a logged-out state before re-authenticating. This disrupts the user experience and makes the app feel less seamless.

That’s where refresh tokens come in. Refresh tokens allow the server to silently renew a user’s session without requiring them to log in again. The user stays logged in, and the session is quietly extended in the background—no flicker, no interruptions.

Here’s how to do deal with it:

1. Detect When the Access Token Is About to Expire:

  • Before the token expires in the getServerSession method, trigger a method to refresh the access token. Let’s call this method refreshAccessToken.

2. Use the oslo Package to Refresh:

  • With the help of the oslo package, you can obtain a new access token by calling
javascript
const response = await client.refreshAccessToken(session.refreshToken || "",
{
credentials: process.env.OIDC_CLIENT_SECRET,
authenticateWith: "request_body",
}
);
  • In case of an error on refresh, make sure to update the session with an error flag and handle it (for example try a OIDC prompt=none login flow on client side)

3. Update the Session with New Tokens:

  • After obtaining the new access token, you need to update the existing session. Lucia makes this straightforward:
javascript
const updatedSession = await lucia.createSession(
userId,
yourUpdatedSession,
{ sessionId: session.id }
);
  • Important: Ensure you use the same sessionId as before. This will update the existing session with the new tokens, instead of creating a new session.

For a more complete example, see the our example app on github.

💣 Important 💣: Avoid storing session data encrypted in a cookie, as NextAuth does by default when you want to use refresh tokens!

Next.js has limitations when it comes to updating cookie values during rendering, which can cause issues with your refresh tokens. For instance, if your app tries to use a refresh token but fails to update the cookie, the next request may still try to use the expired refresh token, leading to errors.

To prevent this, ensure that you store your session data server-side in a reliable database, like Redis. This way, your refresh tokens can be properly managed without relying on cookie updates, ensuring a smoother, more secure authentication flow.

Client Context for Session

To efficiently handle session management together with client components, the following strategies can be used to ensure a consistent and robust integration:

1. Utilize Context to Sync State Between Client and Server:

  • The SessionContext on the client side ensures that the state is accessible across all components. Whenever the server updates the session (e.g., after a successful login or token refresh), the client context is also updated, ensuring both remain synchronized.

2. Leverage useOptimistic and useTransition for Seamless UX:

  • useOptimistic helps manage interim states while the server is processing changes, like when a user updates their profile. This prevents UI flicker and keeps the interface responsive. When a state change is initiated, the client-side optimistic updates will immediately reflect the change, and once the server confirms, the final state is reconciled.
javascript
"use client";
import { useOptimistic, useTransition } from "react";
const RootLayoutProvider = ({
user, // user inside the session coming from server
children,
}: PropsWithChildren<Props>) => {
const [sessionUser, setSessioUser] = useOptimistic<User | null>(user);
const [, startTransition] = useTransition();
return (
<SessionContext.Provider
value={
sessionUser
? {
user: sessionUser,
updateUserSession: (user: DeepPartial<User> | null) => {
if (user === null) {
startTransition(() => {
setSessioUser(null);
});
} else {
startTransition(() => {
setSessioUser(all<User>([sessionUser as any, user]));
});
updateSession();
}
},
}
: { user: null, updateUserSession: () => {} }
}
>
{children}
</SessionContext.Provider>
);

3. Use deepmerge for Efficient Session Updates:

  • Use the Library deepmerge for a nice way to use the updateUserSession method. With use of this library, you're able to just pass in the changed values instead of the full object. for example:
javascript
const { updateUserSession } = useSession();
updateUserSession({alias: 'my-new-alias'});

4. Implement a Validate Session Provider:

  • Implement a Provider that can periodically check if the session is still valid, especially when the user switches tabs. This provider can also handle refreshing the access token before it expires.
javascript
useEffect(() => {
const visibilityHandler = async () => {
if (document.visibilityState === "visible") {
setSessionState(await refetchSessionFromServer());
}
};
document.addEventListener("visibilitychange", visibilityHandler, false);
return () =>
document.removeEventListener("visibilitychange", visibilityHandler, false);
}, []);

💣 Important 💣: Ensure, that the access token is never available in client components.

Dude, just use next-auth, it's much easier

Well, not quite. While NextAuth is definitely a great option for quick setups and smaller projects, it’s not yet a first-class citizen for the App Router. The session context has some limitations, and NextAuth v5 has been in beta for over a year. Plus, when it comes to handling database schema and refreshing tokens, Lucia gives you a lot more flexibility and control.

So, if you need something more customizable, Lucia is a great choice. NextAuth is absolutely fine for smaller projects or when you need to get something up and running quickly, but for more advanced, scalable solutions, Lucia shines.

See the full Code Example at Github

Previous post
Back to overview
Next post