Back to Blog
| 5 min read | Advanced

Secure Multi-Tenant Cookie-Based JWT Authentication for free

My implementation of a cookie-based JWT authentication system explicitly designed for multi-tenant environments. This approach solves the complex challenge of secure authentication across subdomains while maintaining user sessions.

REIstacks Authentication System: Secure Multi-Tenant Cookie-Based JWT Authentication

REIstacks used a comprehensive cookie-based JWT authentication system designed specifically for multi-tenant environments. This approach solves the complex challenge of secure authentication across subdomains while maintaining user sessions.

Architecture Overview

The authentication system combines .NET 9 backend API services with Next.js 15 frontend, using Google OAuth for user registration and secure HttpOnly cookies for token storage.

sequenceDiagram
    participant User
    participant Next.js Frontend
    participant Google OAuth
    participant .NET Backend
    participant TokenService
    participant Browser

    User->>Next.js Frontend: Initiates sign-in
    Next.js Frontend->>Google OAuth: Redirects to Google
    Google OAuth->>Next.js Frontend: Returns Google token
    Next.js Frontend->>.NET Backend: Sends Google token
    .NET Backend->>TokenService: Verifies token, generates JWT
    TokenService->>.NET Backend: Returns JWT
    .NET Backend->>Browser: Sets HttpOnly, Secure Cookies
    Browser-->>.NET Backend: Sends cookies automatically with future requests

Core Components

Backend (.NET 9)

The backend handles token generation, verification, and refresh logistics:

public class TokenService : ITokenService
{
    public string GenerateAccessToken(UserProfile user, Organization org)
    {
        var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT_SECRET"]));
        var signingCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new Claim(ClaimTypes.Email, user.Email),
            new Claim(ClaimTypes.Name, user.Name ?? string.Empty),
            new Claim(ClaimTypes.Role, user.Role.ToString())
        };

        // Add organization claims
        if (org != null)
        {
            claims.Add(new Claim("organization_id", org.Id));
            claims.Add(new Claim("subdomain", org.Subdomain));
        }

        var tokenOptions = new JwtSecurityToken(
            issuer: Constants.JwtConstants.Issuer,
            audience: Constants.JwtConstants.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(30),
            signingCredentials: signingCredentials
        );

        return new JwtSecurityTokenHandler().WriteToken(tokenOptions);
    }
}

Frontend (Next.js 15)

The frontend uses a service-based pattern to manage authentication state:

// AuthClientService.ts
export class AuthClientService {
  private static instance: AuthClientService;

  public static getInstance(): AuthClientService {
    if (!AuthClientService.instance) {
      AuthClientService.instance = new AuthClientService();
    }
    return AuthClientService.instance;
  }

  async getUserProfile(): Promise<any> {
    try {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/current-user`, {
        method: "GET",
        credentials: 'include'  // Important for sending cookies
      });
      
      if (!res.ok) {
        throw new Error(`Failed to fetch profile: ${res.status}`);
      }
      
      return await res.json();
    } catch (error) {
      console.error("Error fetching user profile:", error);
      throw error;
    }
  }
}

The system uses HttpOnly cookies instead of storing tokens in localStorage, providing enhanced security against XSS attacks:

// Setting cookies in the backend
Response.Cookies.Append("access_token", accessToken, new CookieOptions
{
    HttpOnly = true,
    Secure = true,
    SameSite = SameSiteMode.None,
    Domain = ".reistacks.com",
    Expires = DateTime.UtcNow.AddMinutes(30)
});

Multi-Tenant Support

The system supports multiple organizations with subdomains:

// Authentication middleware allowing cross-subdomain auth
app.UseCors(options =>
{
    options.WithOrigins("https://www.reistacks.com", "https://reistacks.com")
          .AllowAnyMethod()
          .AllowAnyHeader()
          .AllowCredentials()
          .SetIsOriginAllowedToAllowWildcardSubdomains();
});

State Management

The frontend uses Zustand for global state management:

// useUserStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { AuthClientService } from '@/services/AuthClientService';

export const useUserStore = create(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      isLoading: true,

      fetchUser: async () => {
        try {
          const userData = await AuthClientService.getInstance().getUserProfile();
          set({
            user: userData ? {
              id: userData.id,
              name: userData.name,
              role: userData.role,
              // other user properties
            } : null,
            isAuthenticated: !!userData,
            isLoading: false,
          });
        } catch (error) {
          set({ user: null, isAuthenticated: false, isLoading: false });
        }
      },

      logout: async () => {
        try {
          await AuthClientService.getInstance().logout();
        } finally {
          set({ user: null, isAuthenticated: false, isLoading: false });
        }
      },
    }),
    {
      name: 'user-storage',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
);

Context API Integration

React Context organizes access to authentication data:

// AuthContext.tsx
'use client';

import { useUserStore } from '@/stores/useUserStore';
import { createContext, useContext, useEffect } from 'react';

const AuthContext = createContext({
  user: null,
  loading: true,
  logout: () => {},
});

export function AuthProvider({ children }) {
  const { user, isLoading, fetchUser, logout } = useUserStore();

  useEffect(() => {
    if (!user) {
      fetchUser();
    }
  }, [user, fetchUser]);

  return (
    <AuthContext.Provider value={{ user, loading: isLoading, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => useContext(AuthContext);

Route Protection

Next.js middleware handles route protection across subdomains:

// middleware.ts
export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // Skip for public routes
  if (isPublicRoute(pathname)) {
    return NextResponse.next();
  }
  
  // Check auth for protected routes
  if (isProtectedRoute(pathname)) {
    return await checkAuth(request, pathname);
  }
  
  return NextResponse.next();
}

async function checkAuth(request: NextRequest, pathname: string) {
  try {
    const authCheck = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/current-user`, {
      headers: {
        "Cookie": request.headers.get("cookie") || "",
      },
    });

    if (!authCheck.ok) {
      const loginUrl = new URL("/sign-in", request.url);
      loginUrl.searchParams.set("redirect", pathname);
      return NextResponse.redirect(loginUrl);
    }

    return NextResponse.next();
  } catch (error) {
    const loginUrl = new URL("/sign-in", request.url);
    return NextResponse.redirect(loginUrl);
  }
}

Security Features

The system implements multiple security best practices:

  1. HttpOnly Cookies: Tokens are never exposed to JavaScript
  2. Secure Flag: Cookies only transmitted over HTTPS
  3. Domain-Specific Cookies: Supports cross-subdomain authentication
  4. Automatic Token Refresh: Background token refreshing
  5. Cross-Site Requests: this setup uses SameSite=None because cross-subdomain auth requires it — which means the cookie attribute provides no CSRF protection here. Pair it with an anti-forgery token or a strict origin check.
  6. OAuth PKCE Flow: Extra protection for OAuth authentication

Usage Example

Using authentication in a component:

'use client';

import { useAuth } from '@/context/AuthContext';

export default function DashboardPage() {
  const { user, loading } = useAuth();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!user) {
    return <div>Please sign in to access the dashboard.</div>;
  }
  
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Your role: {user.role}</p>
      {/* Dashboard content */}
    </div>
  );
}

Conclusion

REIstacks implements a robust authentication system that handles the complexities of multi-tenant applications. By combining secure cookie-based JWTs with OAuth authentication and service-based architecture, it provides a secure and seamless user experience while supporting subdomain isolation.

The organization-aware jwt claims and custom domain support make it particularly well-suited for SaaS applications where users need to access their own organization's data across different subdomains while maintaining proper security boundaries.

Tags

#Software Engineering #Backend #Advanced
Casey Spaulding
Casey Spaulding

.NET Full Stack Engineer. 21 years in the Navy, then software.

Ask Casey AI

Ask Me Anything

Hi! I'm Casey's AI assistant. Ask me anything about his work, skills, or projects!

10 messages remaining