Use NextAuth to authenticate API requests
Cookie settings, CORS, validation, etc.
NextAuth is a great way to authenticate users for your Next.js app. However, Next.js API Routes are not a good substitute for a full backend framework. In this article, we'll address all the setup required to use NextAuth's cookies to authenticate against a separate backend. While I am using NestJS as my backend framework, the general logic/flow is portable to almost any backend framework.
Important Note: You can avoid a lot of this by proxying your API requests through Next.js. This tutorial is specifically for an API server on a separate, subdomain.
The Basic Idea
When a user logs in successfully, NextAuth issues an HttpOnly
cookie that contains either a JWT or a session token. While we could have an API route that issues a different token for accessing the API server, this adds complexity. What if, instead, we could use NextAuth's cookie as our token for the API? This is a good solution because it allows us to reduce complexity, leaving the management of the token to NextAuth. Our backend can then just validate it and move on. This is also assuming that your backend is on a subdomain relative to the domain of your frontend; e.g. your frontend is at example.com
and your backend is at api.example.com
.
The Frontend
NextAuth Config
No matter what your backend is, you'll have to set a custom configuration for the session cookie in NextAuth. By default, NextAuth does not specify a domain for the cookie. This results in a cookie whose domain is that of the current page (i.e. example.com
), not including subdomains. Other than that, all other defaults are fine.
Here's how to set it:
// Inside your NextAuth config object
cookies: {
sessionToken: {
name: `__Secure-next-auth.session-token`, // Make sure to add conditional logic so that the name of the cookie does not include `__Secure-` on localhost
options: { // All of these options must be specified, even if you're not changing them
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: true,
domain: `example.com` // Ideally, you should use an environment variable for this
}
},
}
You might be looking at that domain wondering "How is that different? ". When you specify the domain, the cookie's domain value will get set with an extra period in front (i.e. .example.com
for this) which tells the browser that it's fine to send to subdomains.
Making requests
When you make requests from your frontend, you also need to add an option to the request to send the cookie(s).
fetch
requests: specifycredentials: "include"
in the request config
MDN ReferenceXHR
requests (e.g. Axios):withCredentials = true
MDN Reference
You'll also want to make sure that your chosen method of making requests is able to make CORS preflight requests for some HTTP methods.
The Backend
Headers
For this to work, we need to set up some CORS headers. Here they are:
Access-Control-Allow-Origin
: You must specify the domains; a wildcard (*
) will not allow access to request credentials.
MDN ReferenceAccess-Control-Allow-Credentials
: This must be set totrue
.
MDN ReferenceAccess-Control-Allow-Headers
: Make sure that theCookies
header is included.
MDN ReferenceVary
: Must be set toOrigin
. Most frameworks/libraries set this automatically.
Authentication/Validation
In your backend of choice, you'll need similar conditional logic to above. Assuming that you use the default cookie names, you'll access a cookie called __Secure-next-auth.session-token
or next-auth.session-token
. If this is a JWT, you'll validate it and extract the encoded information - make sure your server uses the same JWT signing secret that you provided to NextAuth. If it's a session token, you'll look it up in your database and make sure it exists + is not expired.
NestJS - Passport
Here's specifically how I implemented this in NestJS, using Passport. While I am still using Nest's Express platform, this should be mostly compatible with the Fastify platform. First, you'll need the cors
package, configured as followed:
app.enableCors({
origin: [
'http://localhost:3000',
'http://127.0.0.1:3000',
// add your other urls here
],
allowedHeaders: ['Cookie', 'Content-Type'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS'],
credentials: true, // This is what sets the `Access-Control-Allow-Credentials` header.
});
Secondly, you'll need the cookie-parser
package.
import cookieParser from 'cookie-parser';
// ... other code
app.use(cookieParser());
Finally, I used the passport-custom
package to set up a custom strategy. I implemented it as follows:
const cookieName = // This turnary statement is the conditional logic I mentioned previously
process.env.NODE_ENV === 'production'
? '__Secure-next-auth.session-token'
: 'next-auth.session-token';
@Injectable()
export class NextAuthSession extends PassportStrategy(
Strategy,
'nextauth-session',
) {
constructor(private authService: AuthService) {
super();
}
// The Request type is imported from Express
async validate(req: Request): Promise<User | null> {
const sessionToken = req.cookies[cookieName];
if (!sessionToken) {
throw new UnauthorizedException({ message: 'No session token' });
}
// authService.verifySession does a database lookup with Prisma
const session = await this.authService.verifySession(sessionToken);
if (!session) {
throw new UnauthorizedException({
statusCode: 401,
message: 'Invalid Session',
});
}
// Whatever you return gets added to the request object as `req.user`
return session.user;
}
}
Conclusion
This was something that took me a bit to figure out properly, especially figuring out how to use Passport. I hope others come across this guide and find it useful.