Cookie-Based authentication for Next.js 13 apps

cibrax

Cibrax

Posted on October 11, 2023

Cookie-Based authentication for Next.js 13 apps

This post is for you if you want a simpler alternative to NextAuth to implement authentication in your Next.js application using Iron-Session and the App Router.

What's iron-session ?

It's a popular open-source project for Node.js for encrypting/decrypting data that can be persisted in cookies. You can find more about the project in Github.
My implementation uses a middleware that relies on iron-session to create an encrypted session cookie for the authenticated user. I wrote two functions: getSession for decrypting the data associated with the authenticated user in the existing session cookie and setSession for creating the session cookie.

import {unsealData} from "iron-session/edge";
import {sealData} from "iron-session/edge";
import {cookies} from "next/headers";

const sessionPassword = process.env.SESSION_PASSWORD as string;
if(!sessionPassword) throw new Error("SESSION_PASSWORD is not set");

export type User = {
    login: string;
}

export async function getSession() : Promise<User | null> {
    const encryptedSession = cookies().get('auth_session')?.value;

    const session = encryptedSession
        ? await unsealData(encryptedSession, {
            password: sessionPassword,
        }) as string
        : null;

    return session ? JSON.parse(session) as User : null;
}

export async function setSession(user: User) : Promise<void> {
    const encryptedSession = await sealData(JSON.stringify(user), {
        password: sessionPassword,
    });

    cookies().set('auth_session', encryptedSession, {
        sameSite: 'strict',
        httpOnly: true,
        // secure: true, # Uncomment this line when using HTTPS
    });
}
Enter fullscreen mode Exit fullscreen mode

It's important to note that the authentication cookie has been set with the following attributes,

  • sameSite: strict, so only requests coming from our application receive the authentication cookie. This prevents cross-forgery attacks.
  • httpOnly: true, so the cookie could not be updated on the client side.
  • secure: true, so the cookie is only sent over https.

Next.js middleware

The new App Router only supports a single middleware function per application. That function must be called "middleware" and also exported in a file "middleware" at the same level as the "app" directory. If you need to address different concerns as middleware, all those should be combined in this function. Probably not a good idea, but that's a topic for another discussion.

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

import { getSession } from "@/services/authentication/cookie-session";

export async function middleware(request: NextRequest) {

    const user = await getSession();

    if(!user) {
        return NextResponse.redirect(new URL('/login', request.url))
    }

    return NextResponse.next();
}

// See "Matching Paths" below to learn more
export const config = {
    matcher: '/((?!api|_next/static|_next/image|favicon.ico|login).*)',
}
Enter fullscreen mode Exit fullscreen mode

Matcher is a regular expression that Next.js uses to determine if the middleware should run or not. The code for this implementation just checks if there is an active session or sends the user to the login page otherwise.

The Login form

The login form combines a component that runs the client side with a server action that authenticates the user and issues the session cookie.

'use client'

import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import Paper from "@mui/material/Paper";
import TextField from "@mui/material/TextField";

import {
    experimental_useFormState,
    experimental_useFormStatus,
} from "react-dom";

import login from "@/app/login/action";

function SubmitButton() {
    const { pending } = experimental_useFormStatus()

    return (
        <Button
            type="submit"
            fullWidth
            variant="contained"
            color="primary"
            style={{ margin: '10px 0' }}
            aria-disabled={pending}
        >
            Login
        </Button>
    )
}

export default function Form() {
    const [state, dispatch] = experimental_useFormState(login, {
        username: '',
        password: '',
    });

    return (
        <Container component="main" maxWidth="xs">
            <Paper elevation={3} style={{ padding: '20px', marginTop: '20px' }}>
                <Typography variant="h5" align="center">Login</Typography>

                <form action={dispatch}>
                    <TextField
                        id='username'
                        name='username'
                        variant="outlined"
                        margin="normal"
                        required
                        fullWidth
                        label="Username"
                        autoFocus
                        aria-describedby={state.error ? "" : undefined}
                    />

                    <TextField
                        id='password'
                        name='password'
                        variant="outlined"
                        margin="normal"
                        required
                        fullWidth
                        label="Password"
                        type="password"
                        autoComplete="current-password"
                    />
                    <SubmitButton/>
                </form>

                {state.error && (
                    <Typography color='red' gutterBottom>
                        {state.error}
                    </Typography>
                )}
            </Paper>
        </Container>
    )
}
Enter fullscreen mode Exit fullscreen mode

The component below representing the view of the login form runs as a client component to use experimental_useFormState. That function allows capturing results back from the server action.

The server action is straightforward; it only authenticates the user and sets the cookie. For the sake of simplicity, the code only checks for "admin" as username and password.

'use server'
import {setSession} from "@/services/authentication/cookie-session";
import {redirect} from "next/navigation";

export default async function login (
    previousState: { username: string; password: string, error?: string },
    form: FormData
) {
    const username = form.get('username');
    const password = form.get('password');

    if(username === 'admin' && password === 'admin') {
        await setSession({ login: 'admin' });

        return redirect('/home');
    }
    else {
        return {
            username: previousState.username,
            password: previousState.password,
            error: 'Invalid username or password'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There is something tricky about the experimental_useFormState function, which requires a client component to be used but a server component acting as a parent to invoke the server action. That part took me a while to figure out. You need a new server component to wrap the form.

import Form from "./form";

export default function Login() {
    return (
        <>
            <Form></Form>
        </>
    );
};
Enter fullscreen mode Exit fullscreen mode

Complete example

You can find the complete implementation in this Github repo

💖 💪 🙅 🚩
cibrax
Cibrax

Posted on October 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related