Use NextAuth to authenticate API requests

Cookie settings, CORS, validation, etc.

·

4 min read

Use NextAuth to authenticate API requests

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: specify credentials: "include" in the request config
    MDN Reference
  • XHR 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 Reference
  • Access-Control-Allow-Credentials: This must be set to true.
    MDN Reference
  • Access-Control-Allow-Headers: Make sure that the Cookies header is included.
    MDN Reference
  • Vary: Must be set to Origin. 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.

Did you find this article valuable?

Support Henrik VT by becoming a sponsor. Any amount is appreciated!