Frontend
Consolidating authentication and authorization into one platform shared by all our products
uehara Dev.to (EN Zone)
1 views
Conclusion
ELN ID is an in-house project run by EarthLink Network as a company, developed by a team of one human owner with AI doing the implementation. What started as an organization-management app grew into the authentication and authorization platform (an Identity Provider, IdP) shared by our products. When we audited which of our seven products recognized whom as an administrator, the number of products using the ServiceRole we had designed for that purpose was zero. So we made the pattern that was actually in use — globalRole === 'ADMIN' || groups.includes('<serviceId>-admin') — the shared rule.
Who counts as an administrator is decided in exactly one place. Whether a token is being issued for the first time or refreshed later, the same resolveScimGroupNames() runs. The UI does not make its own judgment; it simply displays the isAdmin the server computed. This is how we fixed two bugs: administrator-group information disappearing after a token refresh, and the admin screen rejecting users whose API calls were succeeding.
The story (about 8 minutes)
In June 2026, ELN ID — the organization-management app I maintained — had grown over three months into the IdP used by multiple products in the company. In a short period, requests like "please issue us an OAuth client" came in more than fifteen times, and it became clear we needed a shared specification for integrations instead of inventing the rules per product.
Three decisions we made when it became the authentication platform
The trigger was reactive. One product asked us: "we want silent Single Sign-On (SSO — using one authentication across multiple services) plus terms-of-service consent, handled together." That put us at a fork: handle it as a one-off, or turn it into shared rules. I chose the latter and wrote the integration rules down as design records. We decided three things first.
The first was how to implement silent authentication. Silent authentication means checking in the background — without asking the user to type anything — whether they are still logged in, and if so, logging them in automatically. The goal is to avoid showing a login screen every time a user opens an integrated product. Initially we considered the classic approach: place a small invisible window (a hidden iframe) and have it ask "is this user currently logged in?". But on real devices, the answer was always "not logged in". The cause: the cookie ELN ID uses to remember the login state is issued with SameSite=Lax. With that setting, the cookie is not sent to a window embedded in another site. So we restricted silent authentication to a method that briefly navigates the whole page to ELN ID and back (a top-level redirect), banned embedded iframes, and recorded that in the design records.
The second was how to manage consent to the terms of service. A "consent version" here is the marker of which version of the terms a user has agreed to. When the terms are updated, users must consent again. That only works if ELN ID and the integrated products judge "has this user consented to the current version?" identically. If an integrated product starts comparing numbers on its own — "version 2 is newer than version 1, so no re-consent needed" — its judgment will drift from ELN ID's. So we made the version an opaque string that ELN ID decides, with no meaning attached to its contents. The only thing integrated products are allowed to do is check whether the strings match exactly; converting to numbers or comparing order is forbidden. Whether a user has consented is not embedded in the login token (no claim in the id_token); it must always be fetched through the internal verification API (verify-token) and compared, character for character, with the current version.
The third was the decision not to migrate existing consent records. Before consolidating on ELN ID, each product kept its own records of "this user has agreed to the terms". We could have copied those into ELN ID, but we chose not to. Instead, on the first login after the switch, everyone was asked to consent once more. Migrating old records carries the risk of omissions and mix-ups, plus the labor. One round of re-consent at first login avoided all of it. These decisions were finalized as design records in June. The consent mechanism and silent authentication were built in four stages using test-driven development (TDD — writing a failing test first, then implementing), and at the end all 41 tests passed.
Splitting authorization into four parts
As integrations grow, the next thing that matters is deciding who is an administrator. If that stays vague and scattered across products, accidents are guaranteed. So we split authorization into four parts.
globalRole ('ADMIN' | 'USER', stored on the user record in DynamoDB, default 'USER') marks a global administrator.
ServiceRole marks a per-service role.
ServiceTeam marks team membership within a service.
SCIM groups are groups created through SCIM (System for Cross-domain Identity Management — the mechanism that automatically syncs users and groups with external identity systems).
We then audited, one by one, which of our seven in-house products recognized whom as an administrator. On paper, ServiceRole was supposed to drive each product's admin decision. Reality was different.
Admin-decision audit in the field (7 products):
- product-A : globalRole === 'ADMIN' only
- product-B : globalRole + its own RBAC
- product-C : JWT groups claim (<service>-admin) primary, globalRole as legacy fallback
- product-D : JWT groups claim (<service>-admin)
- product-E : globalRole OR staffRole on ServiceTeam
- product-F : no role check at all (valid session = admin / serious authz flaw)
- product-G : not even connected to SSO (hard-coded user/pass)
→ products using ServiceRole for admin decisions: zero
The design records described an ideal, but nobody in the field used ServiceRole; the two products that had gone live first had each independently settled on "is this user a member of the SCIM group <serviceId>-admin?". I abandoned the ideal and promoted the pattern reality had converged on to the standard. This is the new standard form.
// The standard admin check shared by all products (aligned with what the audit found)
const isAdmin =
globalRole === 'ADMIN' ||
groups.includes(`${serviceId}-admin`)
globalRole === 'ADMIN' is reserved for emergency global administrators. Day-to-day, per-service administrators are granted through the <serviceId>-admin SCIM group.
We also fixed the deployment order so that administrators never get locked out. If you change the admin-check code first, then until the corresponding SCIM group and its members exist, every administrator is locked out. So this order became mandatory:
Create the group
Assign the members
Change the admin-check code
Deploy and verify
Why it kept breaking somewhere else after every fix
While moving to this model, authorization bugs kept appearing — all with the same shape: the same decision was being built in two places, and the two drifted.
The first was the bug where administrator-group information disappears when a token is refreshed. When you log in, you receive a token that acts as your ID card. It expires after a while, so a refresh token (refresh_token) is used to get a new one without logging in again. The token carries the list of admin groups the user belongs to (groups), which is how "administrator or not" is decided. But while the initial issuance path (authorization_code) looked up the groups and built the groups claim, the refresh path (issueTokensForRefresh()) had no such lookup and passed undefined. So a user who was an administrator right after login lost every group-granted privilege the moment their token refreshed. Because it only happens on refresh, ordinary tests that verify the first login never caught it.
Our internal security review had flagged exactly this shape. The original finding (in English):
issueTokensForRefresh() (line 586) passes `undefined` as 5th arg to
generateAccessToken — SCIM groups are never fetched in the refresh path
→ any user with group-based access loses JWT groups claim after first
token refresh
The second was the bug where the frontend and the backend look at different places. The backend admin check (withAdminAuth) had been fixed to accept members of the admin group, but the frontend guard (AdminGuard) still looked only at globalRole. As a result, users made administrators via a group could call the API successfully while the screen showed "Access Denied". Building the same decision twice, separately, is what caused the split.
The root cause was that who counts as an administrator was being decided in many places, independently. After the fix, it is decided in one place. In the token code we added a new function, resolveScimGroupNames(userId), shared by issuance and refresh. Both authorization_code (issuance) and refresh_token (refresh) call the same function.
// sso/token/route.ts — both issuance and refresh go through the same resolver
async function resolveScimGroupNames(userId: string): Promise<string[]> {
const { ScimGroupMemberEntity, ScimGroupEntity } =
await import('@/infrastructure/dynamodb/entities')
const membershipResult =
await ScimGroupMemberEntity.query.byUser({ userId }).go()
// ...resolve group names from memberships...
}
// authorization_code grant side
const groups = await resolveScimGroupNames(userId) // line ~309
const accessToken = generateAccessToken(userId, clientId, scopes, globalRole, groups, ttl)
// refresh grant side (inside issueTokensForRefresh)
const groups = await resolveScimGroupNames(userId) // line ~588
const accessToken = generateAccessToken(/* ... */ groups /* ... */)
On the frontend, the server now includes its computed isAdmin in the API response, and the screen only displays it. The admin check itself is consolidated into the server-side userIsInScimGroup(...), leaning toward "deny unless verified".
// withAdminAuth.ts — allow admin-group members even when globalRole is not ADMIN
if (user.globalRole !== 'ADMIN') {
const isOnionAdminGroupMember =
await userIsInScimGroup(userId, ADMIN_GROUP)
if (!isOnionAdminGroupMember) {
return /* 403 */
}
}
What happened next, and what remains
We made it possible to create the administrator-assignment groups (SCIM groups) and add members from the admin screen. In the security review before shipping that screen to production, two critical and two high-risk issues were found, and all were fixed before release. The most dangerous one: when adding a member, we were not checking whether the person belonged to the same organization as the group, so a user from org-B could be added to an org-A group — a path to mixing data across organizations. We closed it by checking that the target organization is included in the member's organizations (targetUser.organizations). This admin-group mechanism was then rolled out to the rest of the products, following the two that had adopted it first.
Meanwhile, the audit findings "product with no role check" and "product not even connected to SSO" remain as separate tasks as of this article. Writing the ideal authorization model that covers everything, and pulling each existing product toward it one by one, turned out to be entirely different kinds of labor — that lesson stung.
Four takeaways:
Write integration rules into design records. Deciding promises like "no iframes", "version is an opaque string with no meaning inside", and "no migration" before implementation prevents drift between you and the integrating products.
Standardize the permission shapes actually in use. The audit showed nobody used ServiceRole, so we changed the standard to SCIM groups.
Decide who is an administrator in one place, and hand it to everyone. Build it separately in the UI, the server, and the token issuance and refresh paths, and the answers will drift. Consolidate into one routine, and deny when you cannot verify.
For permission changes, decide the deployment order too. Create the groups and members first, then change the code. In the reverse order, every administrator gets locked out.
Read original: https://dev.to/uehara/consolidating-authentication-and-authorization-into-one-platform-shared-by-all-our-products-15p
← Previous
AI Coding Assistants for Embedded Systems: 2026 Guide
Next →
Why DNS Had to Exist: The Directory Problem Explained From First Principles
Related
ESLint took 4.4s to lint Vue's core. oxlint took 0.24s. Then I turned on the type-aware rules.
Frontend
4
DEV Community
Power BI Data Modelling, Relationships and Joins.
Frontend
4
Dev.to (EN Zone)
tanstack-fetch: A Typed Fetch Client Built for TanStack Query
Frontend
3
DEV Community
Material vs locked: the rule that lets 2048 and Sudoku share a board
Frontend
4
DEV Community
Comments0
No comments yet — be the first