Auth Architecture SaaS Node.js

Building a Centralized Auth System for a Multi-Product SaaS Platform

One auth service. Three independent products. Kiosks, POS terminals, and mobile apps — all secured differently, all talking to the same identity layer.

March 2026 12 min read

We're building three products — Positeasy (a full POS ecosystem for merchants), FoodBojo (a food ordering platform), and Payiteasy (HRMS and payroll). Each has its own database, its own user roles, its own idea of what a "permission" means.

Early on we asked: do we build auth separately for each product, or do we centralise it? The answer became obvious fast. Three separate auth systems means three places to patch a vulnerability, three token formats to maintain, and no way for a merchant to ever manage staff across products from one place.

So we built one auth service. Here's how we designed it, and why we made the decisions we did.

Auth Service answers: who are you?  Each product answers: what can you do here? These are different questions and should never be conflated.

The Core Principle: Identity vs. Authorization

The most important design decision was separating authentication (proving who you are) from authorization (deciding what you can do). Mixing them is what causes auth systems to become unmaintainable.

The Auth Service owns identity entirely. It issues signed tokens, manages sessions, handles login flows. It knows nothing about billing permissions, payroll access, or menu management — those are product concerns.

Each product service receives a token, verifies it locally, extracts the user identity, then consults its own database for roles and permissions. The Auth Service is never in the request path for authorization.

Clients
Merchant Web POS Terminal Kiosk Mobile App
API Gateway
AWS ALB Rate Limiting TLS Termination
Auth Service
Login / Logout Token Issuance Session Management Device Registry
Product Services
Positeasy API FoodBojo API Payiteasy API
Data Layer
Auth DB (Postgres) Session Store (Redis) Product DBs (isolated)

Token Design: Product-Scoped Audience Claims

We use RS256 JWT — asymmetric signing where only the Auth Service holds the private key. Product services verify tokens using the public key from a JWKS endpoint. Verification is a local operation with no network call.

The key decision: a product-scoped audience claim. A token issued for Positeasy is cryptographically rejected by Payiteasy. Cross-product token misuse is structurally impossible.

JWT Payload — Staff Shift Token
Identity
"sub": "staff_user_uuid",
"iss": "https://auth.platform.io",
"aud": ["positeasy"], // rejected by any other product
Context
"tenant_id": "merchant_uuid",
"device_id": "terminal_uuid",
"device_type": "pos_terminal",
"linked_via": "mobile_pin",
Session
"session_id": "session_uuid",
"shift_started": 1710000000,
"exp": 1710043200 // 12h shift

Three Device Classes, Three Auth Models

We have fundamentally different device types. Forcing them through the same login flow would have been wrong.

DeviceWho AuthenticatesToken TypeLifetime
Merchant WebEmail + passwordStandard JWT15 min / 7 day refresh
POS TerminalDevice pairing + Staff PINDevice cert + Shift token1 year / 12h shift
KioskDevice only — no staffDevice cert + Session1 year / 8h rolling
Mobile (FoodBojo)Customer OTPStandard JWT15 min / 30 day refresh

Kiosk: Rolling Device Session

A kiosk has no user. The device itself is the authenticated principal. A Device Certificate (1 year) pairs the kiosk. A Kiosk Session Token (8 hours) handles API calls, auto-renewed every 7 hours silently. If the merchant deregisters the kiosk, the next renewal returns 403 DEVICE_REVOKED.

Why 8 hours?

Maps to a typical business day. Started at 9am, renews at 4pm, valid until midnight. Short enough that a stolen device is locked out within one working day if deregistered.

POS Terminal: Pairing + Shift Login

Two distinct lifecycles: the device lifecycle (years) and the staff session lifecycle (hours). Handled completely separately. Pairing happens once via QR scan or a pair code for terminals without cameras — both generated simultaneously, pointing to the same Redis entry.

Staff Login: Mobile Number + PIN

The merchant adds staff in Positeasy with their mobile number. That's the source of truth. First login triggers a one-time OTP flow to set a PIN. Every shift after that is just mobile number + PIN — fast, no OTP.

Shift Login Flow
1
Staff enters mobile number on terminal
Terminal sends mobile + device certificate to Auth Service
2
Two independent validations
Device certificate active · Mobile belongs to this merchant in Positeasy
3
Staff enters PIN
bcrypt verified · wrong PIN tracked in Redis · locked after 5 attempts
4
Shift token issued
sub = staff UUID · device_id = terminal UUID · exp = 12h
5
Terminal shows "Welcome, Ravi"
Every bill attributed to this staff member on this terminal
Two-Layer Access Check

Auth Service asks: does this mobile exist, is the PIN correct, is the account active?

Positeasy asks: does this staff belong to this merchant, do they have an active role?

Both must pass. A valid PIN is not enough if the merchant has deactivated that staff in Positeasy.

QR + Pair Code: Two Ways, One Session

Not all terminals have cameras. We generate a QR code and a human-readable pair code simultaneously — both point to the same Redis entry with a 90-second TTL. The terminal uses whichever it can. Once claimed via either method, the other is also invalidated atomically.

// Atomic creation — both point to the same session
await redis.pipeline()
  .set(`sqr:${jti}`,       JSON.stringify(data), 'EX', 90)
  .set(`scode:${pairCode}`, JSON.stringify({jti}), 'EX', 90)
  .exec();

// Claim — both deleted, neither reusable
await redis.pipeline()
  .del(`sqr:${jti}`)
  .del(`scode:${pairCode}`)
  .exec();node.js

Revocation: Instant Control for Merchants

JWTs are stateless — once issued, valid until expiry. Our revocation layer uses Redis. When a merchant removes a device, the Auth Service marks it inactive in PostgreSQL, adds the token's jti to a Redis blacklist (TTL = remaining lifetime), and deletes the session key. The next API call from that device gets a 401 within milliseconds.

Revocation Actions

Remove terminal — device cert blacklisted, all sessions revoked, must re-pair to work again.

Kick staff only — staff session revoked, terminal returns to login screen, device cert untouched.

Suspend device — session revoked, renewals blocked, reactivate without re-pairing.

Force logout all — bulk Redis pipeline blacklists every active session for the tenant in one round trip.

Why Not Auth0, Okta, or Keycloak?

FactorCustom AuthAuth0 / OktaKeycloak
Mobile OTP (India)NativeLimitedPlugin needed
Device pairing flowCustom fitNot supportedNot supported
Cost at scaleInfrastructure only$$$$ per MAUSelf-hosted complexity
Data sovereigntyFull controlSaaS vendorSelf-hosted
Stack fitNode.js / Koa nativeSDK availableREST API only

The deal-breaker was the device pairing model and mobile OTP. Neither Auth0 nor Keycloak have a first-class concept of a POS terminal that pairs once and has staff log into it per shift. We'd have been building that on top of their abstractions anyway.

What We'd Do Differently

Two things in hindsight. First, define the JWKS endpoint contract with product teams earlier — we had a brief period where Positeasy was caching the public key without honouring the kid field, which would have caused silent failures during key rotation.

Second, the linked_via field in the session token was added late. We wanted analytics on how staff log in — QR scan vs. pair code vs. mobile+PIN. Worth designing in from day one.

The overall architecture held up well. One auth service, three products, five device types. The rule that served us best: if a piece of logic needs to know what a billing permission is, it doesn't belong in the auth service.