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
});
}
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).*)',
}
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>
)
}
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'
}
}
}
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>
</>
);
};
Complete example
You can find the complete implementation in this Github repo
Top comments (0)