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=S256After 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=truethat 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=loginor revoke their refresh token via/oauth/revoke. - Listen for webhooks — subscribe to
user.updatedand invalidate your local cache.
Safety notes
- The
groupsclaim is only present when your app has thegroupsscope. 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 beadminin one org andmemberin another. - Never trust group assignments made by the client (e.g. a form field). Only trust what UniAuth signs into the ID token.