NextAuth.js and Capacitor – Session Token Cookie not deleting on signOut

I am using NextAuth.js in my Next.js app to authenticate users via Google Provider and magic link. This works perfectly in a browser but not so well in a native wrapper using Capacitor JS – https://capacitorjs.com/.

To provide some context, here is my setup for authentication. I’m using MongoDB to store user accounts:

// [...nextauth.js]

import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import EmailProvider from "next-auth/providers/email";
import { MongoDBAdapter } from "@next-auth/mongodb-adapter";

import clientPromise from "@/lib/mongodb";
import { AuthVerification } from "@/emails/sign-in";

const THIRTY_DAYS = 30 * 24 * 60 * 60;
const THIRTY_MINUTES = 30 * 60;

const adapterOptions = {
  databaseName: "accounts",
};

export const authOptions = (req) => ({
  pages: {
    verifyRequest: "/auth/verify-request",
  },
  secret: process.env.NEXTAUTH_SECRET,
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    EmailProvider({
      sendVerificationRequest({ identifier, url, provider }) {
        AuthVerification({ identifier, url, provider });
      },
    }),
  ],
  callbacks: {
    async session({ session, user }) {
      if (session?.user) {
        session.user.id = user.id;
        session.user.filters = user.filters;
        session.user.admin = user.admin;
        session.user.stripeCustomerId = user.stripeCustomerId;
        session.user.plus = user.plus;
      }
      return session;
    },
  },
  adapter: MongoDBAdapter(clientPromise, adapterOptions),
});

export default async function auth(req, res) {
  return await NextAuth(req, res, authOptions(req), {
    debug: true,
  });
}

I am triggering the sign in functions via:

// Google Provider

signIn('google', {
  callbackUrl: process.env.NEXT_PUBLIC_BASE_URL,
});

// Email Provider

signIn("email", {
  redirect: false,
  callbackUrl: process.env.NEXT_PUBLIC_BASE_URL,
  email,
})

The issue stems from the fact that in Capacitor, the Google Provider opens in a new browser window so the session-token cookie required for authentication wasn’t passed back to the app.

I decided to setup deep linking and tried to make it work through the Capacitor Browser plugin (https://capacitorjs.com/docs/apis/browser) using this custom signIn function:

async function signIn() {
  const { url } = await fetch(
    `${
      process.env.NEXT_PUBLIC_BASE_URL
    }/api/auth/signin/google`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        csrfToken: await getCsrfToken(),
        json: "true",
        callbackUrl: process.env.NEXT_PUBLIC_BASE_URL,
      }),
      redirect: "follow",
      credentials: "include",
    },
  );

  await Browser.open({ url: url });
}

This generated the correct auth URL and opened in the in-app browser, however once authenticated it just loaded the callbackUrl inside the in-app browser rather than deep-linking back to the app.

After much trial and error, I gave up and ended up focusing solely on the email provider route.

The email generated a URL that successfully deep linked into the app so I caught the link containing the email and token using the Capacitor listener appUrlOpen and hit the api/auth/callback/email route manually:

async function authUser(token, email) {
  try {
    const response = await fetch(
      `${
        process.env.NEXT_PUBLIC_BASE_URL
      }/api/auth/callback/email?token=${token}&email=${email}`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          csrfToken: await getCsrfToken(),
          json: "true",
        }),
        credentials: "include",
      },
    );

    if (response.ok) {
      router.reload();
    }
  } catch (error) {
    console.error("Error during authentication:", error);
  }
}

useEffect(() => {
  App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
    const authUrl = new URL(event.url);
    const params = new URLSearchParams(authUrl.search);
    const tokenRegex = /token=([^&]+)/;
    const match = tokenRegex.exec(event.url);

    if (match && match[1]) {
      const token = match[1];

      authUser(token, params.get("email"));
    }
  });

  return () => {
    App.removeAllListeners();
  };
}, [router]);

This worked great – On success it reloaded the router and the user is authenticated. It also remained authenticated when I exited and reloaded the app so all seemed to be working perfect.

If I inspect the cookies, I can see that the three cookies are all present as they should be:

__Host-next-auth.csrf-token
__Secure-next-auth.callback-url
__Secure-next-auth.session-token

However, the issue comes when I logout. The session-token cookie disappears as it should but then if I try to log back in it just reloads the router but remains unauthenticated.

If I reload the app then the session-token cookie sometimes appears again but I still can’t login. If I manually delete the cookies and try again then it works so I’m thinking that the session-token cookie isn’t getting properly removed on signOut maybe?

For reference, I am obtaining the session info on the client-side using the following way:

import { useSession } from "next-auth/react";

const { data: session } = useSession();

This issue doesn’t happen in Xcode simulator by the way, only when testing on a connected iOS device or when distributed via TestFlight.

Any help or guidance would be greatly appreciated – I’ve been banging my head against the wall for days on this!

Thanks.