DEV Community

hiro@
hiro@

Posted on • Originally published at zenn.dev

Design evolution toward using the same auth and permission model in Next.js and Expo

This article is an English translation of the original Japanese article.

I added an Expo app to a Next.js service that was running on NextAuth. Web uses cookies, mobile uses Bearer JWT. Authentication methods differ, but the session format and permission checks after entering the API are shared.

It did not start in this form. I went through a structure where mobile directly handled Google auth, then a structure using web auth from an in-app browser, before settling on the current boundary.

Web kept NextAuth cookies

Existing web uses NextAuth sessions. Replacing this just to add mobile would affect login, account linking, and existing sessions.

So I kept web as is and added a JWT entry point just for mobile.

Web     -> Cookie -> tRPC context
Mobile  -> Bearer JWT -> tRPC context
                        -> same session.user
Enter fullscreen mode Exit fullscreen mode

Mobile goes through web OAuth

From Expo, I use openAuthSessionAsync to open web authentication. After OAuth completes, the server goes through a short auth flow to return an app JWT, and the app stores it in SecureStore.

const result = await WebBrowser.openAuthSessionAsync(loginUrl, redirectUrl);
Enter fullscreen mode Exit fullscreen mode

API requests put it in the Authorization header.

async headers() {
  const token = await getToken();
  return token ? { authorization: `Bearer ${token}` } : {};
}
Enter fullscreen mode Exit fullscreen mode

Align to the same user format in tRPC context

The server checks cookie session first, then verifies Bearer JWT if absent.

const session = await auth();

if (!session?.user) {
  const header = opts.headers.get("authorization");
  if (header?.startsWith("Bearer ")) {
    const result = await verifyJwt(header.slice(7), secret);
    // Read user from DB, shape to match session.user
  }
}
Enter fullscreen mode Exit fullscreen mode

JWT contains only user ID. name, email, image are fetched from the DB and shaped to match web session.user. This way, code that uses user name in notifications does not need to care about access origin.

Separate authentication and authorization

Whether logged in is checked in protectedProcedure. Operational permissions within organizations are judged by separate authorization helpers.

await assertMember(ctx, organizationId);
await assertAdminOrOwner(ctx, organizationId);
Enter fullscreen mode Exit fullscreen mode

Even if a mobile screen hides a button, API checks are not skipped. Screen permission display is for usability, server authorization is for data protection.

401 is handled once on mobile side

When JWT expires, parallel requests may all return 401 at once. If each query handles logout individually, navigation overlaps.

let isHandlingUnauthorized = false;

async function handleUnauthorized() {
  if (isHandlingUnauthorized) return;
  isHandlingUnauthorized = true;
  await clearAuth();
  router.replace("/login");
}
Enter fullscreen mode Exit fullscreen mode

Rather than fully sharing authentication methods, I shared everything after API context. Web and mobile differ in how they hold credentials. On the other hand, the permission model after obtaining user ID is the same. With this boundary, I could add mobile while keeping existing web.

Top comments (0)