DEV Community

Cover image for From a Broken Side Project OAuth Bug to Enterprise SSO Token Exchange
Adebisi Mosimiloluwa
Adebisi Mosimiloluwa Subscriber

Posted on

From a Broken Side Project OAuth Bug to Enterprise SSO Token Exchange

A Google OAuth bug in my side project accidentally allowed citizens to access an admin dashboard.

Three months later, that same debugging experience helped me contribute to designing a secure cross-application authentication flow at work.

This post walks through the bug, how I fixed it, and how those same ideas translated into an enterprise SSO architecture.


Table of Contents


The Side Project

My emergency management platform, CrisisOps, has a fairly straightforward architecture.

  • Node.js + Express
  • PostgreSQL + Prisma
  • Two frontend applications
    • Citizen Portal
    • Admin Dashboard

Both applications shared the same authentication backend.

Google OAuth handled authentication.

Everything worked...

Until it didn't.


The Bug

During testing, I discovered something worrying.

A citizen could:

  1. Sign in normally.
  2. Open the Admin Dashboard URL.
  3. Be treated as authenticated.

The backend correctly verified that the session existed.

What it never verified was whether that session belonged to the application requesting access.

Instead, the same authentication state leaked across application boundaries.

Architecture Diagram Here

Architecture-Diagram

The problem wasn't broken authentication.

It was cross-application session bleeding.

The backend answered:

"Is this session valid?"

But never asked:

"Is this session valid for this application?"


How I Fixed It

Rather than relying solely on authentication, I added multiple authorization layers.

1. Scope Sessions to Their Origin

Instead of issuing one generic session cookie, the backend first determines which frontend initiated the request.

const getAppSource = (req: Request): 'admin' | 'user' => {
  const headerSource = (req.headers['x-app-source'] as string | undefined)?.toLowerCase();

  if (headerSource === 'admin' || headerSource === 'user')
    return headerSource;

  const cookieSource = req.cookies?.oauth_from?.toLowerCase();

  if (cookieSource === 'admin' || cookieSource === 'user')
    return cookieSource;

  const referer = (req.headers.referer || '').toLowerCase();

  if (referer.includes('admin'))
    return 'admin';

  return 'user';
};
Enter fullscreen mode Exit fullscreen mode

Based on that context, the backend issues application-specific cookies.

  • admin_accessToken
  • admin_refreshToken
  • user_accessToken
  • user_refreshToken

Now each frontend only accepts sessions intended for it.

Note

The Referer header is only a fallback.

In production, the frontend always sends a custom X-App-Source header, making the origin explicit instead of relying on browser behavior.


2. Reject Unauthorized OAuth Requests Early

Instead of issuing tokens first and checking permissions later, the OAuth callback validates the destination before authentication completes.

const from =
  req.cookies?.oauth_from === 'admin'
    ? 'admin'
    : 'user';

if (from === 'admin' && googleUser.role === 'CITIZEN') {
  return res.redirect(
    `${fallbackUrl}/auth/error?message=${encodeURIComponent(
      'Access denied'
    )}`
  );
}
Enter fullscreen mode Exit fullscreen mode

If a citizen attempts to authenticate into the Admin Portal, the login ends immediately.

No admin cookies are ever created.


3. Protect Every Endpoint

Authentication tells the system who you are.

Authorization determines what you're allowed to do.

The backend enforces a role hierarchy.

const ROLE_HIERARCHY: Record<UserRole, number> = {
  CITIZEN: 0,
  RESPONDER: 1,
  DISPATCHER: 2,
  ORG_ADMIN: 3,
  GOV_ADMIN: 4,
  SUPER_ADMIN: 5,
};
Enter fullscreen mode Exit fullscreen mode

Beyond role checks, routes can also require individual permissions such as:

  • incidents:assign
  • resources:deploy
  • audit:read

That keeps authorization flexible without creating dozens of specialized roles.


The Enterprise Challenge

A few months later, our engineering team faced a similar—but much larger—problem.

Users needed to move between multiple internal applications with one click.

No login screen.

No password prompt.

Just seamless authentication.

Immediately we started asking questions.

  • How do we prevent JWT leakage?
  • How do we stop replay attacks?
  • How do we defend against XSS?
  • How do we protect against CSRF?

Our backend engineer proposed using a Handoff Token, inspired by OAuth 2.0 Token Exchange (RFC 8693).

Because I had already spent time solving authentication boundaries in CrisisOps, the architecture immediately made sense.


Designing the Handoff Token Flow

Instead of passing a user's JWT directly between applications, we issue a short-lived, single-use transition token.

Architecture Diagram Here

Architecture-Diagram-2

The destination application exchanges that token for its own session.

The original JWT never crosses trust boundaries.


Security Considerations

Every design decision addressed a specific attack surface.

Preventing Token Leakage

Rather than storing sessions in local storage, the exchanged session is returned as an HttpOnly, Secure, SameSite=Strict cookie.

Even if an attacker successfully executes JavaScript through an XSS vulnerability, the session cookie remains inaccessible.


Preventing Replay Attacks

The handoff token is:

  • Single-use
  • Valid for only a few seconds
  • Immediately invalidated after exchange

Even if intercepted, it becomes practically useless.


Preventing CSRF

Every transition includes a nonce (or state parameter) generated by the originating application.

The Identity Provider validates this value before issuing a handoff token, preventing unauthorized cross-site transitions.


Lessons Learned

Looking back, the OAuth bug wasn't just a bug.

It was my introduction to trust boundaries.

Three lessons stuck with me.

  • Verify at application boundaries—not just user identity.
  • Never pass long-lived tokens between applications.
  • Side projects teach production architecture in unexpected ways.

The debugging session that once felt like an annoying weekend problem eventually became the mental model I relied on during an enterprise authentication design discussion.

Sometimes the bugs that frustrate us the most become the ideas we build systems around.


Final Thoughts

One of the biggest reasons I continue building side projects is that they let me fail in places where failure is inexpensive.

Those failures often become experience long before they're needed professionally.

Have you ever had a side project teach you something that later became useful at work?

I'd love to hear your story.

Top comments (0)