UniAuth.ID

RBAC via the groups claim

UniAuth emits user group membership in the standard OIDC groups claim when an app is granted the groups scope. Use this for role-based access control — not environment variable allowlists.

Anti-pattern: hardcoding an admin email list in [email protected],[email protected] and checking email in ADMINS. That breaks when admins leave, forces a redeploy for every change, and doesn't work for role-based scenarios like "staff" vs "admin" vs "readonly".

The groups scope

Request groups alongside openid when initiating the authorization flow:

GET /api/oauth/authorize?
  response_type=code&
  client_id=uni_…&
  redirect_uri=https://yourapp.com/callback&
  scope=openid+profile+email+groups&
  state=…&
  code_challenge=…&
  code_challenge_method=S256

After the user consents, the ID token and /userinfo response include:

{
  "sub": "2b7f16373f76cbdf9c0241ae59903ee2d143830d4edf74693b608fd2357cc219",
  "email": "[email protected]",
  "email_verified": true,
  "name": "Alice Wang",
  "groups": ["admin", "engineering", "on-call"]
}

Mapping groups → app roles

A common convention: use UniAuth groups as role names, then map them in your app.

Node / Express (openid-client)

app.use(async (req, res, next) => {
  if (!req.session?.claims) return res.redirect('/login')
  const groups = req.session.claims.groups || []

  req.user = {
    email: req.session.claims.email,
    isAdmin: groups.includes('admin'),
    isStaff: groups.includes('admin') || groups.includes('staff'),
    roles: groups,
  }
  next()
})

function requireRole(role) {
  return (req, res, next) => {
    if (!req.user?.roles.includes(role)) return res.status(403).send('Forbidden')
    next()
  }
}

app.get('/admin', requireRole('admin'), handler)

Laravel

// app/Http/Middleware/RequireGroup.php
public function handle(Request $request, Closure $next, string $group)
{
    $groups = $request->user()->claims['groups'] ?? [];
    if (!in_array($group, $groups, true)) abort(403);
    return $next($request);
}

// routes/web.php
Route::middleware(['auth', 'require.group:admin'])
     ->get('/admin', [AdminController::class, 'index']);

Django (mozilla-django-oidc)

# myapp/auth.py
class UniAuthBackend(OIDCAuthenticationBackend):
    def update_user(self, user, claims):
        user = super().update_user(user, claims)
        groups = claims.get('groups', []) or []
        user.is_staff     = 'admin' in groups or 'staff' in groups
        user.is_superuser = 'admin' in groups
        user.save()
        return user

# usage
@user_passes_test(lambda u: u.is_staff)
def admin_panel(request): ...

Spring Boot

public class GroupsAuthoritiesMapper implements GrantedAuthoritiesMapper {
    public Collection<? extends GrantedAuthority> mapAuthorities(
            Collection<? extends GrantedAuthority> authorities) {
        Set<GrantedAuthority> out = new HashSet<>(authorities);
        for (GrantedAuthority a : authorities) {
            if (a instanceof OidcUserAuthority oidc) {
                Object g = oidc.getIdToken().getClaim("groups");
                if (g instanceof Collection<?> c)
                    for (Object grp : c) out.add(new SimpleGrantedAuthority("ROLE_" + grp));
            }
        }
        return out;
    }
}

// in SecurityConfig
.authorizeHttpRequests(a -> a
    .requestMatchers("/admin/**").hasAuthority("ROLE_admin")
    .anyRequest().authenticated())

Where do groups come from?

Four sources, listed in typical enterprise priority:

  • SCIM provisioning — if your IdP (Okta, Azure AD, JumpCloud) pushes groups via SCIM, members arrive with their existing group assignments. Admins don't manage them manually; the upstream IdP is the source of truth.
  • Organization membership — orgs auto-create a group per member role (owner, admin, member) that can be used in RBAC decisions.
  • Manual admin assignment — admins can create groups and add/remove members at /admin/groups.
  • System groups — default groups marked is_system=true that cannot be deleted (e.g. authenticated).

Refreshing group membership

Groups are embedded in the ID token / access token at issuance. If membership changes after issuance, the token still reflects the old state until it expires (default 1 hour). Options:

  • Wait — natural token rotation refreshes claims hourly.
  • Call /userinfo — always returns current claims, including current groups. Trade network latency for freshness.
  • Force re-auth — send the user through prompt=login or revoke their refresh token via /oauth/revoke.
  • Listen for webhooks — subscribe to user.updated and invalidate your local cache.

Safety notes

  • The groups claim is only present when your app has the groups scope. Check for its presence (claims.groups ?? []) rather than assuming it exists.
  • Group names are human-editable. Prefer namespaced names (eng:admin, billing:readonly) so app-level meaning is unambiguous.
  • For multi-tenant apps, combine groups with org membership (org_id) — a user might be admin in one org and member in another.
  • Never trust group assignments made by the client (e.g. a form field). Only trust what UniAuth signs into the ID token.

Next