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.
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.
"sub": "staff_user_uuid",
"iss": "https://auth.platform.io",
"aud": ["positeasy"], // rejected by any other product
"tenant_id": "merchant_uuid",
"device_id": "terminal_uuid",
"device_type": "pos_terminal",
"linked_via": "mobile_pin",
"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.
| Device | Who Authenticates | Token Type | Lifetime |
|---|---|---|---|
| Merchant Web | Email + password | Standard JWT | 15 min / 7 day refresh |
| POS Terminal | Device pairing + Staff PIN | Device cert + Shift token | 1 year / 12h shift |
| Kiosk | Device only — no staff | Device cert + Session | 1 year / 8h rolling |
| Mobile (FoodBojo) | Customer OTP | Standard JWT | 15 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.
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.
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.
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?
| Factor | Custom Auth | Auth0 / Okta | Keycloak |
|---|---|---|---|
| Mobile OTP (India) | Native | Limited | Plugin needed |
| Device pairing flow | Custom fit | Not supported | Not supported |
| Cost at scale | Infrastructure only | $$$$ per MAU | Self-hosted complexity |
| Data sovereignty | Full control | SaaS vendor | Self-hosted |
| Stack fit | Node.js / Koa native | SDK available | REST 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.