In this journey, we're diving into setting up session-based authentication using Firebase Auth and Next.js 14.
Instead of checking a user's auth state on the client side like Firebase intended, we're taking it up a notch by doing it on the server.
We can check the auth state directly in the server before sending our response to the client. No more useEffect calls and re-rendering components to reflect the auth state of the user.
Server Environment and Firebase
Firebase is a client side package and not suitable for our needs. It provides a safe way to handle auth in the client for those applications without a backend. In this tutorial though, we have a backend and want to use serverActions and server side logic to determine auth state of the user. We rely on firebase/auth for user credential management instead of rolling our own auth service which is quite risky and complicated.
To run firebase operations in the server, we must use firebase-admin library provided by firebase. It targets a node.js environment which is the environment of next.js server actions and components unless we specify runtime="edge".
middleware auth?
I know some of you may want to authenticate a request using the middleware.ts but beware that the firebase-admin package only runs in nodejs environment and wont work in the middleware which only supports runtime="edge".
Project Outline
In this basic auth demonstration, we will implement these:
Sign in using firebase/auth client side library and providers (google, github etc)
Ability to Sign out / delete account using server actions
Ability to read user information and auth state in the server before rendering.
Here are the routes used in the project. I've omitted the components etc. You may view them in the repo.
Im skipping the part where we initialize a new next.js project with typescript and tailwind and delete the pre-generated content.
We just need the firebase packages to get started: npm i firebase firebase-admin.
Now we need 2 firebase app instances. One for the client side and one for the server side.
Initialize firebase client app
Lets create a /lib folder in our root directory and make a new file for the client side firebaseApp instance.
We will initialize a firebase app and create an auth object as well.
To get these values, first create a new firebase app using the firebase console then select the web application to generate these.
I've stored the values in my .env file for ease of use.
We are now ready to sign in using firebase. Before continuing go to firebase console, enable authentication and add google as a provider so we can login using google accounts.
Login using firebase and google provider
To get a users credential, we must initiate a sign in flow using one of the firebase providers. Assuming we have enabled google as an auth provider in the firebase console, lets create a basic login page at user/login/page.tsx
As you can see this is a server component. We need a client component for the firebase sign in flow. Lets create the <GoogleSignIn /> button as a client component to handle sign in using a popup as explained in firebase/auth documentation.
Note that the actual component has some additional features like loading state and error handling. Please view the repo for details
As you may have noticed, this client button will call a server action loginAction if firebase auth succeeds.
We need to implement the server side of things for this to work but you can just comment that line out and console.log(token) to verify login was successful.
Firebase-Admin SDK
To access the server side methods of firebase, lets create a lib/firebase-admin.ts file and initialize a firebaseApp which we can use in the server environment.
To connect to firebase using the admin sdk, go to project settings > service accounts and generate a private key.
We need to read this json file to connect to firebase-admin but this is a problem when deploying the app. We somehow need to store this json data in the .env so we can easily access it.
How to save service account json as environment variable
The solution implemented in this example expects a base64 encoded string from the .env named process.env.FIREBASE_CERT_JSON.
It'll first decode the string and then it'll run JSON.parse and get the original json. But for this to work we need to convert our service account json to a string and encode it in base64. In the repo you'll see a credential.js file to see how its can be achieved. After placing your json file in the /private directory and pasting its name in the credential.js, run node credential.js to generate the base64 encoded string in the /private folder. You may now copy and paste this string to your .env file and access it using process.env.FIREBASE_CERT_JSON
Server Actions
Now that we have our admin app ready, we can create the login action to call when signing in. Lets create user/actions.ts and mark it as "use server" so next.js knows these are server actions.
Notice how we call revalidatePath("/") so the router and server cache is revoked. Without this, our auth state would not be reflected in the server components without a hard refresh.
In this server action we call the login function. Lets create this function in lib/firebase-auth-api.ts. We can use this file to store all firebase-admin/auth related functions like login, logout etc...
With this last part implemented, our sign in page should now work.
Request flow
When we successfully sign in at the client side, we extract the token and send it to loginAction server action.
Server action then uses the login() function to verify the token using admin sdk.
If all succeeds, a cookie is created and sent back to the user.
Further requests will now include the cookie in the headers and we can write another server action to verify user auth state using this cookie we receive in the request.
Verify auth state and get user data
Now that we are logged in, how do we protect routes / get the user data if exists in server components?
Lets create isLoggedIn and getCurrentUser helper functions in our firebase-auth-api
With these functions, we may now verify our session before rendering a component. Note that isLoggedIn will only validate the cookie if checkForRevocation param is false. Is is recommended by firebase that we shouldn't invoke revalidate in every request because it'll hit the backend each time. Only do this when needed in sensitive areas or before sensitive operations.
Protected Routes
We need a new server action to get our auth state in actions.ts
Lets create our /user/page.tsx which will show our user profile data using the getAuthAction.
Lets make it so that if we are not logged in, we are redirected to login page.
As you can see we can check for a user in 1 line and redirect if not found! Note that this must be done using a serverAction like we did here using getAuthAction or calling revalidatePath when we say logout may not refresh data in this route.
Logging Out
Now that we have a way of verifying auth, lets create a server action and a logout function
actions.tsx
As you can see, we can directly initiate a redirect through a server action. Make sure not to wrap it in try-catch block because server action redirects work using throwing NEXT_REDIRECT error object.
Lets also create the logout logic in the firebase-auth-api.ts
Avoiding Stale Cache
As mentioned before, when our auth state changes, for the server components to re-render, we must call the revalidatePath function in a server action when necessary. If we don't clear the cache, when we visit /user after logging out, we may still see our user data as stil authenticated. revalidatePath('/user') will remove the cache and re-render this page so we will not be able to see our profile.
Note that there is still some problems with this granular revalidating approach. It's said to be working in the server side cache but when we call revalidatePath, the whole client router cache will be revoked. See this github comment
Conclusion
Many more features can be implemented and more firebase features can be used in the application. Visit the completed project repo or working example for details. Note that, you can delete your account after logging in.