← All posts
Architecture9 min readProject: Central auth service

One login, many apps: designing a central auth service with SSO and app-scoped tokens

Every new internal app came with its own login, its own users and its own idea of permissions. Here is how I designed one auth service for all of them: a global session for single sign-on, a separate token per app, a strict rule that only the auth service can mint tokens, and request checks that never call the auth service at all.

#Authentication#SSO#JWT#Microservices#NestJS#Redis#System Design

The problem

Each internal app handled login, users and permissions on its own, so users logged in many times and admins managed access in many places.

The result

One auth service: users log in once, every app gets its own scoped token, and backends verify requests locally without calling the auth service.

Stack

NestJSJWTRedisPostgreSQLPrismaOdoo

The problem

Our company runs several internal applications: a product configurator, a data source service, admin tools, and more on the way. Each one was built at a different time, and each one solved authentication on its own:

  • its own login screen,
  • its own copy of who the users are,
  • its own way to decide who can do what,
  • its own cookies, its own token format, its own bugs.

For users, that meant logging in again and again. For admins, it meant giving someone access in five places, and forgetting to remove it in one of them when they left. For developers, it meant re-implementing the riskiest code in the system in every new project.

The goal was clear: one auth service for every app. Log in once, move between apps freely, and manage every permission from one admin panel. The hard part was designing it so that it stays secure, fast and simple for each app to connect to.

The details

Who does what

The architecture has three layers, and each has a very clear job:

               ┌──────────────┐
               │ Auth service │  identity, sessions, permissions, token minting
               └──────┬───────┘
          ┌───────────┼───────────┐
          ▼           ▼           ▼
     Backend A    Backend B    Backend C    verify tokens, store cookies
          ▼           ▼           ▼
    Frontend A   Frontend B   Frontend C    talk only to their own backend
LayerDoesNever does
Auth serviceauthenticates users (through the company ERP), manages the global session, resolves permissions, mints tokensserve app data
App backendverifies tokens locally, asks the auth service for tokens, stores them in HttpOnly cookiesmint or sign tokens
Frontendtalks to its own backendtalk to the auth service, read auth cookies

Two rules carry most of the security:

  1. The frontend never talks to the auth service. All the sensitive exchanges happen server to server.
  2. Backends never mint tokens. Only the auth service holds the power to create them.

Two kinds of credentials

  • A global session. One per user login, stored in Redis with a 30-day lifetime, identified by a random ID in an HttpOnly cookie that every app's backend can read. This is what makes single sign-on possible.
  • App-scoped tokens. One access token and one refresh token per app, signed JWTs that carry the user's identity, the app they were minted for, and version numbers. The user's permission list for that app only is resolved when the token is minted and cached in Redis, where the app reads it.

A token for the configurator can't be used on the data source service, and the configurator never sees the data source's permissions. Each app knows exactly what it needs and nothing more.

The challenges

Single sign-on without giving every app a master key

The simplest SSO is "share one token everywhere". But then every app trusts a token that carries every app's permissions. A breach in the least secure app exposes all of them.

Fast requests

If every API request had to ask the auth service "is this user allowed?", the auth service would become a bottleneck and a single point of failure for every app at once.

Revocation

JWTs are fast because they're self-contained, and that's exactly why they're hard to revoke. Logging someone out, or removing a permission, must not wait for a token to expire.

Authentication vs. authorization

A person can prove who they are (valid company login) and still not be allowed into a given app. Mixing those two checks leads to confusing errors, or worse, to "logged in" users with no rights who look like a bug.

The solution

1. First login

1. User clicks "Log in" in App A
2. Frontend A → Backend A → starts the login flow with the auth service
3. Auth service authenticates the user through the company ERP
4. Auth service checks: is this user allowed into App A?
5. If yes, it returns: App A access token, App A refresh token, global session ID
6. Backend A stores all three as HttpOnly cookies

Step 4 is a separate check, on purpose. Passing step 3 proves identity. Step 4 decides access. If step 4 fails, the response is an explicit permission denied, never a token with an empty permission list, because that would look like a successful login to both the app and the user.

2. Moving to another app: the session exchange

1. User opens App B; the browser sends the global session cookie
2. Backend B has no App B tokens, but it finds the global session ID
3. Backend B → auth service:  POST /exchange-session { sessionId, app: "B" }
4. Auth service: is the session valid? is the user allowed into App B?
5. If yes: App B access + refresh tokens → stored as App B's cookies

The user never sees a login screen. And if they're not allowed into App B, the global session stays perfectly valid: a "no" from App B is never a reason to log the user out of everything or make them re-authenticate.

Simplified, the exchange looks like this:

async exchange(sessionId: string, appName: string) {
  const session = await sessions.get(sessionId);
  if (!session) throw new UnauthorizedException('Session is invalid or has expired.');

  const app = await apps.findByName(appName);
  if (!app) throw new NotFoundException(`Application "${appName}" is not registered.`);
  if (app.status !== 'ACTIVE') throw new ForbiddenException(`Application "${appName}" is disabled.`);

  if (!session.isRootAdmin) {
    const access = await access.resolve(app.id, session.userId);
    if (!access.hasAccess) throw new ForbiddenException(`You do not have access to "${appName}".`);
    // The resolved list goes to a Redis cache that the app reads on each request
    await versions.cachePermissions(session.userId, app.id, access.permissions);
  }

  const payload = {
    userId: session.userId,
    name: session.name,
    app: app.name,
    appId: app.id,
    tokenVersion: await versions.token(session.userId),
    permissionsVersion: await versions.permissions(session.userId, app.id),
  };
  return { accessToken: sign(payload, '8h'), refreshToken: sign(payload, '7d') };
}

Notice the order: the app status is checked before the Root Admin shortcut. Root Admins skip permission resolution, so if the status check lived inside it, a Root Admin could walk into a disabled app.

3. Normal requests: no call to the auth service

On every API request, an app backend:

  1. verifies the JWT signature locally,
  2. reads two version numbers from Redis (is the session revoked? are the permissions stale?),
  3. checks that the route's required permission is in the user's permission list for this app (read from the same Redis),
  4. allows (200) or denies (403).

No network call to the auth service. Redis reads take well under a millisecond, and the auth service can be restarted without interrupting anybody's work. (How apps check those versions on their side is covered in "Blocking a user mid-session". How the auth service decides which versions to change is covered in "Making an admin change apply everywhere, immediately".)

4. Refresh goes through the auth service, never through the app

When an access token expires, the app backend sends the refresh token to the auth service, which validates it, re-resolves the user's current permissions, and mints a new access token.

Why not let each backend refresh tokens itself? Because that would require every backend to hold the signing secret. Then a breach of any backend would let an attacker mint valid tokens for any user, any permission, any app. Keeping minting in one place keeps that power in one place.

There's a bonus: every refresh is a fresh permission check. Combined with version numbers, a permission change is picked up on the very next request, not at the next natural expiry.

The refresh path applies the same rules as login:

  • the refresh token's token version must match Redis, or the user must log in again,
  • the user must still be active,
  • the app must still be active,
  • the user must still have access to that app.

5. Logout is global

Logging out of any app ends the global session, so the user is logged out of every app. That matches what people expect from SSO: "log out" means "log me out", not "log me out of this tab". An admin can also force logout a user: every one of their sessions is deleted from Redis (each user's session IDs are tracked in a set, so this is one read and one delete), and their token version is bumped, so tokens already issued stop working immediately.

6. Connecting a new app is (almost) zero work

Onboarding an app takes three steps:

  1. The Root Admin registers the app in the auth service. It receives an API key, which is stored encrypted on the auth side.
  2. The app exposes a manifest of its permissions. The auth service pulls it (see "Permissions the apps declare themselves").
  3. Admins give groups access to the app and tick permissions (see "Groups by department").

The app's backend copies a small, standard guard and a few constants. Everything else (users, groups, permissions, sessions) already exists.

Lessons learned

  • Separate identity from access. "Who are you?" and "may you enter this app?" are different questions with different errors.
  • One session for SSO, one token per app for safety. Sharing a session is convenient; sharing permissions is dangerous.
  • Only one service mints tokens. Every backend that can sign tokens is a backend whose breach compromises everything.
  • Keep the auth service off the request path. Local JWT checks plus Redis reads scale with every app and survive auth restarts.
  • Refresh is a permission check in disguise. Re-resolve permissions on every refresh.
  • Order your checks deliberately. Check that the app is active before any admin shortcut.
  • A denial for one app is not a logout. Keep the global session valid when a single app says no.
  • Make onboarding boring. If connecting a new app is easy, every new app will use the central system.

FAQ

What is the difference between SSO and a shared token?

With SSO, the user logs in once and each app gets its own token, scoped to that app. A shared token is one credential used everywhere, so any app that receives it can do whatever the token allows in every other app.

Should each microservice be able to sign JWTs?

Generally no. If every service holds the signing secret, a breach of any one of them lets an attacker forge tokens for all of them. Keep token minting in the auth service and let other services only verify.

How can apps verify users without calling the auth service on every request?

Use signed JWTs that apps verify locally with a shared secret or public key, and keep a small amount of revocation state (like per-user version numbers) in a fast shared store such as Redis.

What should happen when a user is authenticated but not allowed into an app?

Return an explicit permission-denied response for that app, keep their global session valid, and don't force them to log in again. Logging in again won't change an access decision.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us