Validating Next-Auth JWE Tokens in NestJS: A Step-by-Step Guide

Table of contents

Hey there! ๐
Today I'm diving into a topic that had me puzzled for longer than I'd like to admit: validating Next-Auth JWE tokens in a NestJS backend.
If you've ever tried to build a full-stack application with Next.js on the frontend and NestJS on the backend, you've probably encountered this challenge. Your frontend is smoothly authenticated with Next-Auth, but your backend API is clueless about the identity of the request sender.
Sound familiar? Let's solve that.
The Problem We're Solving
Picture this: you've got a sleek Next.js frontend with Next-Auth handling authentication beautifully. Users can sign in, sessions are managed automatically, and everything works perfectly. But then you need to make API calls to your NestJS backend, and suddenly you realize your backend has no idea if the user is authenticated or not.
The challenge is that Next-Auth uses JWE (JSON Web Encryption) tokens stored in HTTP-only cookies by default. These aren't your typical JWT tokens that you can just decode anywhere โ they're encrypted and need the same secret that Next-Auth uses.
Setting Up Next-Auth (The Foundation)
First things first, let's make sure your Next-Auth setup is correct. If you haven't already, follow the official Next-Auth installation guide.
The key configuration you need is to use JWT sessions:
const options: NextAuthConfig = {
session: { strategy: 'jwt' }
}
This tells Next-Auth to use JWT tokens instead of database sessions, which is exactly what we need for our backend validation.
Don't forget to generate your auth secret:
npx auth secret
This command generates a secure secret and adds it to your .env
file. This secret is crucial โ your NestJS backend will need the exact same secret to decrypt the tokens.
Extracting the Token from Next.js
Here's where it gets interesting. Next-Auth stores the JWE token in an HTTP-only cookie, which means your frontend JavaScript can't access it directly. But we need to send this token to our backend somehow.
Here's a server-side function that extracts the token:
'use server';
import { cookies } from 'next/headers';
export const getNextAuthToken = async (): Promise<string | null> => {
try {
const cookieStore = await cookies();
const cookieName = process.env.NODE_ENV === 'production'
? '__Secure-authjs.session-token'
: 'authjs.session-token';
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return null;
}
return token;
} catch (error) {
console.error('Error getting next auth token:', error);
return null;
}
};
Now you can use this token in your API requests as a Bearer token:
const token = await getNextAuthToken();
const response = await fetch('/api/protected-endpoint', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
The NestJS Side: Creating the Auth Guard
Now for the fun part โ validating these tokens in your NestJS backend.
Here's the complete Next-Auth guard that handles everything:
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
interface NextAuthTokenPayload {
sub: string; // User ID (standard JWT claim)
email?: string;
name?: string;
picture?: string;
iat: number; // Issued at
exp: number; // Expiration time
jti: string; // JWT ID
}
@Injectable()
export class NextAuthGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException();
}
const validationResult = await this.validateToken(token);
if (!validationResult.isValid || !validationResult.payload) {
throw new UnauthorizedException();
}
return true;
}
private extractToken(request: Request): string | undefined {
// Try Authorization header first
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (type === 'Bearer' && token) {
return token;
}
// Fallback to cookie
const cookieName = process.env.NODE_ENV === 'production'
? '__Secure-authjs.session-token'
: 'authjs.session-token';
return request.cookies?.[cookieName];
}
private async validateToken(token: string): Promise<{
isValid: boolean;
payload?: NextAuthTokenPayload;
error?: string;
}> {
try {
const { decode } = await import('next-auth/jwt');
const payload = await decode({
token,
secret: this.configService.getOrThrow<string>('AUTH_SECRET'),
salt: process.env.NODE_ENV === 'production'
? '__Secure-authjs.session-token'
: 'authjs.session-token',
}) as NextAuthTokenPayload;
if (!payload) {
return { isValid: false, error: 'Invalid token payload' };
}
if (payload.exp && this.isTokenExpired(payload.exp)) {
return { isValid: false, error: 'Token has expired' };
}
return { isValid: true, payload };
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : 'Token validation failed',
};
}
}
private isTokenExpired(exp: number): boolean {
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
return exp < currentTimeInSeconds;
}
}
Using the Guard in Your Controllers
Now you can protect your endpoints easily:
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { NextAuthGuard } from './next-auth.guard';
@Controller('api')
export class ApiController {
@Get('protected')
@UseGuards(NextAuthGuard)
getProtectedData(@Request() req): string {
return 'This is a protected route'
};
}
@Get('public')
getPublicData(): { message: string } {
return 'This is a public route'
}
}
The Critical Details That Matter
Here are the gotchas that took me way too long to figure out:
1. Version Compatibility: Your NestJS backend needs to use the same version of the next-auth
package as your Next.js frontend. Different versions can have different encryption methods.
2. Secret Matching: The AUTH_SECRET
environment variable must be identical in both your Next.js and NestJS applications. One character difference and nothing works.
3. Salt Parameter: The salt
parameter in the decode function should match the cookie name. This is often overlooked but crucial for proper decryption.
4. Cookie Names: The cookie names are different between development and production environments. Make sure you handle both cases.
Installing Dependencies
Don't forget to install the required packages in your NestJS project:
npm install next-auth
npm install @nestjs/config # if you're not already using it
What This Gets You
Once you have this setup working, you get:
Seamless authentication between your Next.js frontend and NestJS backend
Access to the authenticated user's information in your protected routes
Automatic token validation and expiration checking
Support for both Bearer tokens and cookie-based authentication
The Real Talk
Setting this up isn't just about copying and pasting code. When I first tried to implement this, I spent hours debugging why my tokens weren't validating. The issue? I had a typo in my environment variable name. One letter. Hours of debugging.
The lesson? Pay attention to the details. Environment variables, package versions, cookie names โ they all matter. And when something isn't working, start with the basics: are your secrets matching? Are you using the same package versions?
What's Next?
This guard gives you a solid foundation for authentication, but you might want to extend it further. Maybe add role-based access control, or integrate with a user management system. The beauty of having this foundation in place is that you can build on top of it.
Have you run into similar authentication challenges? What tripped you up the most when setting up full-stack authentication? I'd love to hear about your experiences.
Remember, every complex system is built one piece at a time. Don't get overwhelmed by the bigger picture โ focus on getting each piece working, and before you know it, you'll have a robust authentication system running.
Subscribe to my newsletter
Read articles from Yannick directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
