DEV Community

ackermannQ
ackermannQ

Posted on

The Subtle Art of Trust: Syncing Auth Between Frontend, Backend, and Database

How to architect a fullstack authentication system where your frontend, backend, and database all trust each other without leaking security or breaking developer flow.

Introduction

Authentication in a modern fullstack app isn't just about logging in-it's about establishing a durable trust contract between all parts of your system: the frontend, the backend API, and the data layer (often managed via a service like Supabase or a custom DB). And while frameworks like NextAuth, Firebase, or Supabase Auth promise convenience, the reality is often messier.

This article explores how to design a seamless, secure, and maintainable authentication pipeline where each layer communicates just enough to stay in sync-without coupling too tightly or leaking user data.


The Core Problem: Fragmented Auth Contexts

In a typical React+Node stack, you’ll often run into the following:

  • Your frontend holds a session via cookies (e.g., via NextAuth).
  • Your backend expects a Bearer token or session ID in headers.
  • Your database needs a user ID or tenant ID to apply row-level access control.

These systems often don’t agree on who the user is, unless you design the protocol and flow intentionally.

Real-World Symptoms

  • session.user exists in the frontend, but req.user is undefined on the backend.
  • Auth tokens exist, but are missing or stale in client-side requests.
  • Supabase rules fail silently because the user's ID isn't passed correctly.
  • Your app works in dev, but breaks in prod due to cookie domain mismatches.

Principle 1: One Source of Truth for Identity

Pick one place to derive identity, and propagate that downward.

  • If using NextAuth, let the session hold the canonical user.id.
  • When making client -> server calls, always inject that identity as a Bearer token or signed cookie.
  • On the backend, validate this token and extract the user.
  • Use the resulting user.id as a consistent key for all DB-level permissions.
// client/apiClient.ts
const session = await getSession()
config.headers.Authorization = `Bearer ${session?.user.id}`
Enter fullscreen mode Exit fullscreen mode

Principle 2: Auth is a Flow, Not a Snapshot

Too many apps treat auth as a static object: session = {...}. But sessions evolve:

  • Users get logged out.
  • Tokens expire.
  • Accounts are deleted or permissions change.

Each layer (frontend, backend, database) should revalidate auth context when needed.

  • Use middleware on the API to validate every incoming token.
  • Keep token lifetimes short and refresh seamlessly.
  • Avoid caching stale identities.
// backend/middleware/auth.ts
const token = req.headers.authorization?.split(" ")[1]
const user = await getUserFromToken(token)
if (!user) return res.status(401).send("Unauthorized")
req.user = user
Enter fullscreen mode Exit fullscreen mode

Principle 3: Use Context-Passing, Not Global State

Avoid relying on implicit global state like sessionStorage, cookies, or req.session-unless you control the whole pipeline. Instead, pass identity explicitly in requests.

This avoids issues like:

  • Inconsistent behavior between SSR and client-side calls
  • Undebuggable bugs in CI/CD environments
  • Session mismatch across subdomains
fetch("/api/data", {
  headers: { Authorization: `Bearer ${session.user.id}` },
})
Enter fullscreen mode Exit fullscreen mode

Advanced Techniques and Deep Dives

🔐 Secure Token Storage

One of the most critical aspects of auth implementation is the secure storage of tokens.

  • Access tokens should never be stored in localStorage due to XSS vulnerabilities.
  • Refresh tokens should be stored in HttpOnly cookies, making them inaccessible to JavaScript and more resilient to XSS attacks.

Recommended pattern (SSR + CSR hybrid apps)

  1. Store the refreshToken in a secure HttpOnly cookie after login.
  2. Create a protected /api/token endpoint that reads the cookie and returns a fresh accessToken.
  3. On the frontend, call this endpoint to get an accessToken and store it only in memory.
// server-side: set secure cookie
res.setHeader(
  "Set-Cookie",
  serialize("refreshToken", token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
  })
)

// client-side: fetch access token
const res = await fetch("/api/token")
const { accessToken } = await res.json()
Enter fullscreen mode Exit fullscreen mode

This ensures that no token ever sits in storage where an attacker could grab it.


🔁 Refresh Token Flow

Access tokens should be short-lived. When they expire, your client must:

  1. Detect the 401 Unauthorized error.
  2. Trigger a refresh flow using the secure cookie.
  3. Retry the original request transparently.

Example interceptor logic

apiClient.interceptors.response.use(undefined, async error => {
  if (error.response?.status === 401) {
    await fetch("/api/token/refresh") // Refresh happens via secure cookie
    return apiClient.request(error.config) // Retry the original request
  }
  return Promise.reject(error)
})
Enter fullscreen mode Exit fullscreen mode

On the backend:

// POST /api/token/refresh
const refreshToken = req.cookies.refreshToken
const user = await validateRefreshToken(refreshToken)
if (!user) return res.status(401).send("Invalid refresh token")
const newToken = createAccessToken(user)
res.json({ accessToken: newToken })
Enter fullscreen mode Exit fullscreen mode

This creates a resilient auth pipeline that gracefully recovers without user interruption.


🧭 Multi-Provider Identity Normalization

When users can log in with different providers (GitHub, Google, credentials), it's easy for your system to lose track of who they are.

Strategy

  • Create a users table that maps external provider IDs to a unified internal userId.
  • Always store and propagate this unifiedUserId.
// Backend
function resolveUnifiedUser(providerId: string, provider: string): string {
  const user = db.users.findOne({ provider, providerId })
  return user?.internalId
}

// Attach this to token payloads
const token = sign({ userId: unifiedId }, SECRET)
Enter fullscreen mode Exit fullscreen mode

By resolving all external identities to one consistent ID, you avoid edge cases where the same person gets treated as multiple users.


💥 Bug Case: Cookie Auth but API 401

Symptom: User logs in successfully, but every API request returns 401 Unauthorized.

Cause: The cookie storing the token is scoped to /api, but frontend fetches are made from /.

Diagnosis

  • Use DevTools > Application > Cookies to inspect path restrictions.
  • Look for missing Authorization header or absent cookie.

Solution

// Correct path on server
serialize("token", value, { path: "/" })
Enter fullscreen mode Exit fullscreen mode

Always scope cookies to / unless you are certain you want to restrict them.


🧵 Session vs Database Identity Drift

Problem: Session token exists, but the user was deleted from the database.

This often causes silent failures-frontend appears logged in, but the backend refuses operations.

Solution

Always verify backend token and check user existence in DB:

const userId = await getCurrentUserId(req);
if (!userId) {
  logger.error("User not found");
  res.status(401).json({ error: "Unauthorized" });
  return;
}

if !(await db.users.exists(user.id)) {
  return res.status(403).send("Invalid session or deleted user");
}

export async function getCurrentUserId(req: Request): Promise<string | null> {
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
  return (token?.id as string) ?? null;
}
Enter fullscreen mode Exit fullscreen mode

This guards against stale or orphaned sessions.


🧪 Debugging and Traceability Enhancements

To effectively debug and audit auth flows:

  • Add a trace ID to every request.
  • Return contextual headers during development.
  • Log token contents and associated user at the edge.

Example

// Middleware
const traceId = uuid()
req.traceId = traceId
logger.info({ traceId, userId: req.user?.id })

// Response headers (dev only)
res.setHeader("X-Trace-Id", traceId)
res.setHeader("X-User-Id", req.user?.id)
Enter fullscreen mode Exit fullscreen mode

Use logs and these headers to correlate frontend/backend issues precisely.


🛡️ Role-Based and Attribute-Based Access Control

A flat user.id isn't always enough. You need roles (e.g. admin, editor) or scopes (repo:read, repo:write).

Encode roles in your token

const token = sign({ userId, role: "admin" }, SECRET)
Enter fullscreen mode Exit fullscreen mode

Then check them in protected routes:

if (decoded.role !== "admin") return res.status(403).send("Forbidden")
Enter fullscreen mode Exit fullscreen mode

In Supabase (with RLS):

CREATE POLICY "Admins can see all"
  ON "logs"
  FOR SELECT USING (auth.role() = 'admin');
Enter fullscreen mode Exit fullscreen mode

Combine token claims and RLS policies for full-stack enforcement.


🧠 When to Build vs Delegate Auth

As a Senior or Staff-level engineer, choosing not to fully delegate authentication to platforms like Supabase Auth or Clerk isn’t about reinventing the wheel - it’s about owning the trust contract between your application layers: frontend, backend, and database.

However, fully delegating authentication essentially depends on your project, team, timeframe and budget. Like a lot of decisions, it’s a balancing act.

When to Build

  • When you need a custom auth system that meets your exact needs.
  • When you need a custom auth system that integrates with your existing infrastructure.
    • e.g. integrating with an existing database, or integrating with a third-party API.

When to Delegate

  • When you don't need to have specfic auth flows or features.
  • When you don't need to integrate with existing infrastructure.
  • When you need to have a quick, simple auth system rapidly built.

Key strategic reasons

  • Full control over session propagation across SSR, API routes, and RLS database layers.
  • Support for hybrid authentication flows (OAuth, credentials, internal accounts).
  • Avoiding vendor lock-in for long-term maintainability and architectural freedom.

✅ TL;DR

  • Frontend, backend, and database must agree on who the user is.
  • Treat authentication as a living flow, not a frozen object.
  • Avoid implicit global state; pass identity explicitly.
  • Use short-lived access tokens + secure HttpOnly cookies.
  • Normalize external accounts with a unified user ID strategy.
  • Include roles and scopes in your tokens and enforce them across the stack.

Conclusion

Syncing authentication across the frontend, backend, and database isn’t trivial - but it becomes manageable when grounded in clear architectural principles:

  1. Establish a single source of identity.
  2. Design authentication as a renewable, revalidating flow.
  3. Pass identity context intentionally across system boundaries.

Get these right, and your system becomes more secure, observable, and adaptable - without sacrificing developer velocity or user experience.


Top comments (0)