diff --git a/docs/architecture/backend/authentication.md b/docs/architecture/backend/authentication.md
index eb5091693..6bd445aa2 100644
--- a/docs/architecture/backend/authentication.md
+++ b/docs/architecture/backend/authentication.md
@@ -78,6 +78,79 @@ export interface M2MTokenResponse {
}
```
+## 🆔 Identity Claims: `username` vs `sub`
+
+Two distinct identifiers travel on the OIDC user (`req.oidc.user`), and choosing the wrong one breaks upstream lookups. They are **not** interchangeable.
+
+### What each one is
+
+| Claim | Example | Shape | Source claim(s) |
+| ------------------------------ | ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| **`sub`** (Auth0 subject) | auth0|jdoe | Provider-prefixed, opaque, globally unique per identity | `user.sub` |
+| **`username`** (LFID username) | `jdoe` | Bare LF login handle, no provider prefix | `user['https://sso.linuxfoundation.org/claims/username']`, `user.nickname`, `user.username`, `user.preferred_username` |
+
+- **`sub`** identifies the **Auth0 identity record**. It carries a connection prefix (`auth0|`, `github|`, `samlp|`, …), so the same person can have different `sub` values across connections. Treat it as an opaque token — never parse it, and never display it as if it were a username. Stripping the connection prefix is misleading: the bare value only coincidentally matches the LFID handle today and is not guaranteed to, so a stripped `sub` is not a substitute for `username`. Two call sites still use `getEffectiveSub(req)`: `badges.controller.ts` resolves verified emails via the auth-service (which also accepts a username or email), and `mktg-agents.controller.ts` binds chat sessions to their creator via internal owner tokens (`createSessionOwnerToken`/`verifySessionOwnerToken`). Neither path requires the prefixed `sub` upstream — badges passes it incidentally, and marketing agents uses it only for internal session binding.
+- **`username`** identifies the **LF person** by their LFID login handle (bare form, no prefix) and is what most upstream microservices index on going forward. Org role grants (`org-identity.controller.ts`, `org-navigation.service.ts`, `org-role-grants.service.ts`) query `b2b_org_settings` with `tags: ['member:${username}']` where `username` comes from `getEffectiveUsername(req)`. On surveys, `creator_username` holds the bare nickname and `creator_id` is set from the `https://sso.linuxfoundation.org/claims/username` claim.
+
+### ID token vs access token — where the claims actually live
+
+Auth0 issues **two** JWTs per session, and they carry identity differently. This split is the central complication of the `sub` → `username` migration.
+
+| | **ID token** | **Access token** |
+| -------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| Lives on | `req.oidc.user` (typed as `User`) | `req.oidc.accessToken.access_token`, decoded via `decodeJwtPayload()` into `LfxAccessTokenClaims` |
+| `sub` | `user.sub` | `claims.sub` |
+| username claim | `https://sso.linuxfoundation.org/claims/username` (plus `nickname` / `username` / `preferred_username`) | `http://lfx.dev/claims/username` — **different namespace** |
+| Consumed by | The BFF only (SSR, analytics, persistence, the `getEffective*` helpers) | Forwarded upstream as `Authorization: Bearer` to the Go microservices |
+
+Note: `sub` is the only identifier present in both tokens under the same key. The username claim is namespaced differently in each (`https://sso.linuxfoundation.org/...` in the ID token vs `http://lfx.dev/...` in the access token), so any code bridging the two must map between namespaces — they are not the same key.
+
+**Worked example — impersonation bridges the namespaces by hand.** Impersonation discards the target's ID token and rebuilds identity entirely from the exchanged **access token**, copying its `http://lfx.dev/claims/username` into every ID-token username slot so both namespaces resolve to the same handle (`server.ts`):
+
+```ts
+Object.assign(auth.user, {
+ sub: targetClaims.sub,
+ username: targetClaims['http://lfx.dev/claims/username'] || '',
+ 'https://sso.linuxfoundation.org/claims/username': targetClaims['http://lfx.dev/claims/username'] || '',
+ nickname: targetClaims['http://lfx.dev/claims/username'] || '',
+ // ...
+});
+```
+
+> **`getUsernameFromAuth()` naming.** For Authelia tokens it returns `preferred_username`; for Auth0 tokens it falls back to `getEffectiveUsername(req)`. The name is still easy to misread — prefer `getEffectiveUsername` directly when you need the LFID handle.
+
+### When to use which
+
+| Use case | Use |
+| ------------------------------------------------------------------------------------- | ------------ |
+| Calling an upstream microservice / query-service API that keys on the LF login handle | **username** |
+| Persisting an author/owner/creator (`creator_id`, role grants, changelog viewer) | **username** |
+| Analytics / observability user identity (DataDog RUM, OpenFeature targeting key) | **username** |
+| Per-caller cache keys for user-scoped data | **username** |
+
+> **Default to `username`.** `sub` has been phased out of backend identity references and no upstream currently requires it — see the migration note below.
+
+### Server-side helpers (impersonation-aware)
+
+Prefer the impersonation-aware helpers in `apps/lfx-one/src/server/utils/auth-helper.ts` when resolving effective caller identity (author/owner, cache keys, upstream params) instead of reading directly off `req.oidc.user`. They transparently return the **target** user's identity during impersonation and the session user's otherwise. Session-scoped enrichment that does not need impersonation swap (e.g. survey `creator_id` from the OIDC session) may still read `req.oidc.user` directly.
+
+| Helper | Returns | Status |
+| --------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for all new identity references |
+| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — two remaining callers (badges email lookup, mktg-agents session owner binding) pass `sub` incidentally; no upstream requires it |
+| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | For email-keyed lookups |
+
+### Migration: `sub` → `username`
+
+Backend identity references have migrated from the Auth0 `sub` to the LFID `username`. In this repo:
+
+- **Front-end (ID token):** DataDog RUM `id`, OpenFeature `targetingKey`, and survey `creator_id` now read `https://sso.linuxfoundation.org/claims/username` (OpenFeature no longer falls back to `sub` — existing LaunchDarkly rules keyed on sub values need updating before deploy).
+- **BFF call sites:** `getEffectiveSub` → `getEffectiveUsername` in changelog, copilot, org-identity, org-navigation, org-lens-access, and org-membership cache keys; `project.service.ts` uses `resolveEmailToUsername` (not `resolveEmailToSub`) for permission and user-info lookups against the plain-LFID `b2b_org_settings` index.
+- **Upstream matching:** the platform authorization proxy (Heimdall) derives the FGA principal from the access token's username attribute (falling back to `client_id@clients` for M2M), not `sub` — so the Go microservices authorize on the LFID handle.
+- **`getEffectiveSub`** is annotated `@deprecated` in `auth-helper.ts`; two callers remain — badges email lookup (auth-service also accepts username or email) and mktg-agents chat session owner binding (internal token only) — both are cleanup candidates, not upstream requirements.
+
+When adding new code, use `username`. No upstream currently requires the prefixed `sub`; if you ever hit one that does, use `getEffectiveSub` and note why inline.
+
## 🏗 Server-Side Implementation
### Auth Context Injection
diff --git a/docs/architecture/backend/impersonation.md b/docs/architecture/backend/impersonation.md
index 8f600f6ec..a2dc29936 100644
--- a/docs/architecture/backend/impersonation.md
+++ b/docs/architecture/backend/impersonation.md
@@ -15,13 +15,16 @@ Impersonation uses Auth0's Custom Token Exchange (CTE) feature (RFC 8693) to obt
The Auth0 side is managed in the `auth0-terraform` repo:
- **CTE Action** (`lfx_impersonation_token_exchange.js`) — validates the requestor, looks up the target user via Management API, and calls `api.authentication.setUserById()` to issue a new token
-- **`can_impersonate` claim** — added to LFX v2 access tokens via `custom_claims.js` for users matching an email allow-list
+- **`can_impersonate` claim** — added to LFX v2 access tokens via `src/actions/custom_claims.js` for authorized impersonators (see Authorization below)
- **Token Exchange Profile** — maps the LFX v2 API `subject_token_type` to the impersonation CTE action
- **Auth Service Client** — the "LFX V2 Auth Service" client has `token_exchange` enabled with `allow_any_profile_of_type: ["custom_authentication"]`
### Authorization
-Only users whose email matches the allow-list regex in the Auth0 action receive the `can_impersonate` claim. The allow-list is maintained in `src/includes/impersonation.js` in the `auth0-terraform` repo.
+The `can_impersonate` claim is granted in `src/actions/custom_claims.js` (`auth0-terraform`), and the authorization rule differs by tenant:
+
+- **Production** — the user's per-client group list in `app_metadata` (`groups-`) must include `lfx-self-serve-allowed-impersonators`.
+- **Dev tenant** (`linuxfoundation-dev`) — any verified `@linuxfoundation.org` or `@contractor.linuxfoundation.org` email qualifies, since Auth0 groups are non-trivial to set up there.
### Token Exchange Flow
@@ -158,27 +161,30 @@ This is the single choke point — every controller and service uses `req.bearer
Many controllers and services read the user's email/username from `req.oidc.user` for server-side filtering (e.g., "get my meetings"). During impersonation, `req.oidc.user` is still the real user. Three helpers resolve the correct identity:
-| Helper | Returns |
-| --------------------------- | --------------------------------------------- |
-| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) |
-| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname |
-| `getEffectiveSub(req)` | Impersonated sub or OIDC sub |
+| Helper | Returns | Notes |
+| --------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `getEffectiveEmail(req)` | Impersonated email or OIDC email (lowercased) | Email-keyed lookups |
+| `getEffectiveUsername(req)` | Impersonated username or OIDC nickname/username/preferred_username | **Preferred** for identity references (LFID username, e.g. `jdoe`) |
+| `getEffectiveSub(req)` | Impersonated sub or OIDC sub | **`@deprecated`** — Auth0 sub (prefixed, e.g. auth0|jdoe); two incidental callers (badges email lookup, mktg-agents session owner binding) |
+
+For the full `username` vs `sub` distinction and the `sub` → `username` migration, see [`authentication.md`](./authentication.md#-identity-claims-username-vs-sub).
These check `req.appSession['impersonationUser']` first, falling back to `req.oidc.user`. All controllers/services that filter by user identity use these helpers (meetings, events, committees, votes, surveys, mailing lists, documents, analytics, badges, persona detection).
-**Exception:** The profile controller always uses `req.oidc.user` directly because profile operations (change password, update metadata, link identities) must act on the real user's account.
+**Profile controller — partially impersonation-aware.** The profile controller resolves identity through `getUsernameFromAuth()`, which falls back to the impersonation-aware `getEffectiveUsername`. So most profile operations (get/update metadata, identities, work experiences, project affiliations, email verify/link) follow the **effective** identity and act on the **target** user during impersonation. The exception is password change and reset: they go through the separate `/api/profile/auth/start` management-token flow, which identifies the user by the management token itself (tied to the real user's re-authenticated session) and is not impersonation-aware.
### 6. SSR Handler
**`apps/lfx-one/src/server/server.ts`**
-During SSR, the handler:
+During SSR, the handler runs in this order:
-1. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token
-2. When impersonation is active, overrides `auth.user` with the target user's claims (sub, email, username, name, picture)
-3. Sets `auth.impersonating = true` and `auth.impersonator` with the real user's identity
+1. Builds `auth.user` from the OIDC session (initially the real user)
+2. Runs persona detection (`resolvePersonaForSsr`)
+3. Populates `auth.canImpersonate` by decoding the `can_impersonate` claim from the access token
+4. When an active impersonation session exists, overrides `auth.user` with the target user's claims (sub, email, username, name, picture) and sets `auth.impersonating = true` + `auth.impersonator`
-Persona detection (`resolvePersonaForSsr`) runs after the override, so it resolves the target user's persona.
+Note that persona detection (step 2) resolves the **target** user's persona even though it runs **before** the `auth.user` override (step 4). It does so not because of ordering but because `resolvePersonaForSsr` reads identity through the `getEffective*` helpers, which consult `req.appSession['impersonationUser']` directly — independent of `auth.user`.
### 7. Frontend
@@ -229,7 +235,7 @@ Every request made under impersonation is logged at DEBUG level with opaque iden
```text
impersonation_request: Request under impersonation
- impersonator_sub: auth0|asitha
+ impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe
path: /api/user/meetings
```
@@ -238,7 +244,7 @@ Impersonation start/stop events are logged at INFO level:
```text
impersonation_granted: Impersonation session started
- impersonator_sub: auth0|asitha
+ impersonator_sub: auth0|jsmith
target_sub: auth0|jdoe
impersonation_stopped: Impersonation session ended
@@ -246,7 +252,7 @@ impersonation_stopped: Impersonation session ended
## Limitations
-1. **Profile operations are not impersonated** — the profile controller always operates on the real user's account. Viewing another user's profile page during impersonation shows the real user's profile data, not the target's.
+1. **Most profile operations follow the impersonated identity** — the profile controller resolves the user via `getUsernameFromAuth()` → `getEffectiveUsername` (impersonation-aware), so viewing and updating profile metadata, identities, work experiences, and project affiliations during impersonation acts on the **target** user, not the real user. Password change and reset are the exception: they use the management-token profile-auth flow tied to the real user's re-authenticated session.
2. **Write operations use the target's identity** — creating meetings, committees, or votes while impersonating will attribute them to the target user (via the bearer token). The `created_by_name` field on committees is an exception (uses the real user's name).
@@ -260,7 +266,7 @@ impersonation_stopped: Impersonation session ended
## Future Work
-- **Read-only profile viewing** — allow impersonators to view (but not modify) the target user's profile page
+- **Read-only profile during impersonation** — profile reads already follow the target user, but writes (metadata, identity linking) do too; gate profile writes so impersonators can view the target's profile without modifying the account
- **Impersonation audit dashboard** — a dedicated UI for reviewing impersonation logs
- **Session duration controls** — configurable max impersonation duration, auto-expiry notifications
- **Impersonation notifications** — optionally notify the target user when they are being impersonated
diff --git a/docs/architecture/frontend/feature-flags.md b/docs/architecture/frontend/feature-flags.md
index 6e9f2f7bf..b84a4bf07 100644
--- a/docs/architecture/frontend/feature-flags.md
+++ b/docs/architecture/frontend/feature-flags.md
@@ -154,7 +154,7 @@ export class FeatureFlagService {
kind: 'user',
name: user.name || '',
email: user.email || '',
- targetingKey: user.preferred_username || user.username || user.sub || '',
+ targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'],
};
await OpenFeature.setContext(userContext);
@@ -752,19 +752,21 @@ const userContext: EvaluationContext = {
kind: 'user',
name: user.name || '',
email: user.email || '',
- targetingKey: user.preferred_username || user.username || user.sub || '',
+ targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'],
};
```
+> **Identity.** `targetingKey` uses the LFID username claim chain above — not Auth0 `sub`. LaunchDarkly rules keyed on legacy `sub` values stop matching after deploy; update them to LFID usernames. See [Authentication — Identity Claims](../backend/authentication.md#-identity-claims-username-vs-sub).
+
**Context Structure:**
-| Field | Type | Required | Description |
-| ----------------- | ------ | -------- | ----------------------------------- |
-| `kind` | string | Yes | Always 'user' for user contexts |
-| `targetingKey` | string | Yes | Unique identifier for the user |
-| `name` | string | No | Display name for LaunchDarkly UI |
-| `email` | string | No | Email for targeting rules |
-| Custom attributes | any | No | Additional attributes for targeting |
+| Field | Type | Required | Description |
+| ----------------- | ------ | -------- | ---------------------------------------------------------------------- |
+| `kind` | string | Yes | Always 'user' for user contexts |
+| `targetingKey` | string | Yes | LFID username (`preferred_username` → `username` → SSO username claim) |
+| `name` | string | No | Display name for LaunchDarkly UI |
+| `email` | string | No | Email for targeting rules |
+| Custom attributes | any | No | Additional attributes for targeting |
**Targeting Use Cases:**
@@ -778,7 +780,7 @@ const userContext: EvaluationContext = {
```typescript
const userContext: EvaluationContext = {
kind: 'user',
- targetingKey: user.sub,
+ targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'],
name: user.name,
email: user.email,
// Custom attributes for targeting
@@ -1448,8 +1450,8 @@ See [Runtime Configuration Troubleshooting](../../runtime-configuration.md#troub
1. **Missing Targeting Key:**
```typescript
- // targetingKey is required for user identification
- targetingKey: user.preferred_username || user.username || user.sub || '';
+ // targetingKey is required — LFID username, not Auth0 sub
+ targetingKey: user.preferred_username || user.username || user['https://sso.linuxfoundation.org/claims/username'],
```
2. **Incorrect Attribute Names:**