LOADING BASE0%
Portfolio Logo
Back to posts
SecurityOAuthNext.jsAuthentication

OAuth 2.0 & JWT Best Practices with NextAuth

4 min read

NextAuth is Magic (If You Understand JWTs)

Handling authentication used to mean writing complex Passport.js strategies, setting manual HttpOnly cookies, and dreading every OAuth callback implementation. Now, NextAuth (Auth.js) turns Google/GitHub logins into a 5-minute setup.

But what happens when you need to connect your Next.js frontend to a separate custom Node.js backend using a JWT?

1. The Custom Provider Workflow

If you own the backend (like our standard user-service at XRide Labs), you don't just want NextAuth to use a generic database provider. You need NextAuth to send credentials to your backend, and retrieve an access token.

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
 
export const authOptions = {
  providers: [
    CredentialsProvider({
      name: "Your App",
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        // Send to your separate Node or Python backend
        const res = await fetch("https://api.yourdomain.com/v1/auth/login", {
          method: "POST",
          body: JSON.stringify(credentials),
          headers: { "Content-Type": "application/json" },
        });
 
        const user = await res.json();
        if (res.ok && user) return user;
        return null;
      },
    }),
  ],
  callbacks: {
    // We append the backend token into the NextAuth JWT
    async jwt({ token, user }) {
      if (user) {
        token.accessToken = user.token;
        token.role = user.role;
      }
      return token;
    },
    // And then pass it to the Session so the client can read it
    async session({ session, token }) {
      session.accessToken = token.accessToken;
      session.user.role = token.role;
      return session;
    },
  },
};

2. Refresh Token Rotation

A common mistake is returning an access token valid for 30 days. Never do this! If a token is stolen, the attacker has a full month to freely hit your API.

Best Practice:

  • Access tokens should live 15-30 minutes only.
  • A Refresh Token (stored securely in an HttpOnly cookie if possible) lives for 7-30 days.
  • In NextAuth, you can intercept the jwt callback to check if the current accessToken is expired, and if so, fire a silent request to your backend's /refresh endpoint to get a new one before returning the token to the user.

3. Protecting Server Actions

In Next.js 14, standard pages can check session validity, but Server Actions are open to the world directly.

YOU MUST AUTHENTICATE YOUR SERVER ACTIONS:

// app/actions.ts
"use server";
 
import { getServerSession } from "next-auth";
import { authOptions } from "./api/auth/[...nextauth]/route";
 
export async function deleteUserResource(id: string) {
  const session = await getServerSession(authOptions);
 
  if (!session || !session.user) {
    throw new Error("Unauthorized! Action denied.");
  }
 
  // Verify Role-Based Access Control
  if (session.user.role !== "ADMIN") {
    throw new Error("Insufficient permissions.");
  }
 
  // Proceed with DB mutation
}

Security isn't a feature you bolt on later. Build your platforms securely from day one.