One Login, Many Apps
How we built SSO for Routina
Routina is no longer one product. Next to the Platform there's a growing family of “Apps by Routina”: CPMS, OMS, 3PL, Totes, CRM and more. Some customers need their people to reach one or two of those apps and never the Platform. That one requirement shaped everything else. Sign-in could not be a feature inside the Platform's backend, so we built a separate service, routina-sso-auth. It is the one place people sign in and the only place app tokens are issued.
Who owns what
We split the problem along one line:
- The SSO service owns identity: users, passwords, two-factor, sessions, app access and signing keys.
- Our existing auth service owns tenancy: organizations, teams, permissions and which modules an org pays for.
When the SSO service is about to issue a token, it asks the tenancy service for the person's current standing in that org. Neither system copies the other's data, so neither can drift out of date. A user has the same ID in both.
To use an app, a person needs three things: their org has the app's module switched on, they're on a team with a permission that actually grants work access, and they've been given access to that app. “Platform” is just another app in this model, with no special back door.
The token swap: the browser never holds a JWT
Each app follows the same flow:
- The app's frontend asks its own backend (its BFF, “backend for frontend”) for a PKCE challenge. It then calls
auth.routina.tech/handoffwith credentials included. - If there's no SSO session, the person goes to the sign-in page and comes back. If they're signed in but not allowed in, the app gets a clear 403 with a reason, so there's no login loop. If they're allowed in, the app gets a one-time code that expires in 60 seconds.
- The frontend passes that code to its BFF. The BFF redeems it at
/tokenusing its own client secret and the PKCE verifier, and gets back a short-lived JWT (10 minutes by default) addressed to that one app.
The JWT stays on the server. The browser only ever holds an opaque session cookie (__Host--prefixed, SameSite=Strict), so a cross-site scripting bug in an app has no token to steal.
Several checks make the code safe to hand to the browser:
- Origin pinning:
oms.routina.techcan only ever get a code for the OMS app. - PKCE (S256 only): a stolen code can't be redeemed without the verifier.
- Client binding: a Platform backend can't redeem an OMS code.
- Single use: one atomic database update decides which redemption wins.
- A second decision:
/tokenchecks access again instead of trusting the earlier one, so access removed in the 60-second gap still takes effect.
JWKS and keys that never leave KMS
Tokens are signed with ES256. In production the private key lives in AWS KMS and never leaves it. Every token header names its key (kid). The public keys are published at /.well-known/jwks.json.
Key rotation is a small state machine. A new key is published in the JWKS before anything signs with it, and it waits there for as long as verifiers are allowed to cache the JWKS. A retired key stays published for 24 hours, well past any token's lifetime. As a result, no verifier ever sees a token signed by a key it hasn't seen yet.
We also ship one verifier package, @routina-tech/sso-verify, with a fixed algorithm allowlist and token-type check. Every service uses it, so nobody writes their own, slightly different version.
Service-to-service
Backend services get tokens through standard grants:
client_credentialsreplaces shared internal secrets.- Token exchange (RFC 8693) lets an app act for a user in another app. It only ever narrows that user's permissions, and the token names the delegate explicitly with an
actclaim.
Machine tokens can never be issued for the Platform's audience, so a service token can never pass for a signed-in person.
Security habits we kept
- Every secret is stored hashed: session cookies, reset links and client secrets are stored only as hashes. A leaked database row is useless on its own.
- A tamper-evident audit log: it's append-only and hash-chained. The application layer takes an advisory lock so the chain can't fork, and database triggers reject any update or delete. Each audit write happens in the same transaction as the change it records, so if the audit write fails, the change rolls back too.
- Lockout stays private: a locked account gets exactly the same response as a wrong password, and login takes the same time whether or not the email exists.
- Revocation reaches the apps: signed back-channel notices tell each app's backend when a session ends or someone's access changes, so a revoked user is cut off right away instead of when their token expires.
- Idle tabs don't stay signed in: refresh only happens while the person is active (HIPAA automatic logoff), so an untouched tab can't keep the session alive.
- Locked-down pages: sign-in pages are rendered on the server, and the Content Security Policy forbids inline scripts.
The result
People sign in once and reach exactly the apps they're entitled to. Each app's backend holds a short-lived token that it can verify against a public JWKS. Every decision along the way is recorded in a log that can't be quietly rewritten.


