The operational constraint is account recovery: a media worker may prove control of an account without proving that every old newsroom membership should return with it. Short answer: keep user identity global, keep application authorization tenant-scoped, and treat a device fingerprint as a risk input rather than proof that either boundary has been satisfied.
That choice prevents a successful reset from becoming a side door into another publication's drafts, subscriber exports, or billing controls. It also gives the on-call team a decision it can explain: authentication establishes the subject, recovery changes the confidence attached to that subject, and a current tenant membership grants a specific action.
Trust is scoped.
How should tenant-aware user identity shape application authorization during account recovery?
Start with a bounded incident exercise. An editor uses the same email address for Tenant A and Tenant B. After losing a phone, the editor completes an account-recovery path on a familiar laptop; its device fingerprint produces a low-risk score. The new session still contains Tenant B as the last active workspace, but the editor's Tenant B membership was removed the previous week. If the application checks only that the session is authenticated, the reset can silently restore access that the tenant already revoked. Trace the request one decision at a time: recovery established control of the subject, the fingerprint changed a risk estimate, the stale workspace selection named Tenant B, and none of those events recreated the removed membership. The invariant is therefore narrower than “a recovered user may sign in.” A recovered subject may request access, while the application must resolve the requested tenant from the server-side resource or route, load current membership for that exact subject-and-tenant pair, evaluate the requested permission, and apply any recovery restrictions. A device signal can raise or lower the need for another factor. It can't mint membership, select a tenant, or override a revocation. This distinction also clarifies response behavior: bad credentials should receive a generic 401 response so that the login surface doesn't reveal whether an account exists, as the OWASP Authentication Cheat Sheet recommends, while an authenticated subject without the required tenant permission receives the application's consistent denial response, commonly 403. Log the more detailed internal reason with protected identifiers, and don't let clients choose the authoritative tenant by merely sending a mutable claim.
No shortcut fixes that.
Model recovery as a confidence change, not a role grant
A useful domain model has four independently changeable records: a subject, authenticators attached to that subject, tenant memberships, and recovery state. Roles and permissions belong to a membership, not to the subject. The session may cache identifiers and authentication context for performance, but the authorization decision needs a freshness policy for membership revocation; a long-lived role claim with no invalidation path is capacity-friendly right up to the moment it becomes a security incident.
Account recovery paths deserve separate policy because they don't carry equal evidence. A password reset through a verified channel, a support-assisted recovery, and reauthentication through an existing trusted authenticator can all return control of the same identity, yet the application can restrict sensitive actions until its required evidence is present. OWASP specifically calls for reauthentication after risk events and recommends rotating or invalidating sessions after reauthentication. That supports a clean sequence: recover the subject, issue a fresh session, then evaluate tenant access and step-up requirements for the requested action.
The fingerprint belongs in that evaluation as telemetry. Browser updates, shared edit bays, privacy controls, and device replacement can change it; conversely, possession of a familiar device doesn't show that the operator still belongs to a newsroom. Store the minimum signal needed for the risk decision, limit its retention, and make the policy's effect observable. The exact threshold will vary with the fingerprint method and threat model, so it should come from a documented risk review and replay testing rather than a number copied from another service.
Don't hide this state in one isTrusted boolean. A compact decision record can carry subject_id, tenant_id, membership_version, authentication time, recovery state, risk outcome, requested permission, and policy version. That is enough to answer the question an incident commander will ask later: “Why did this request pass?”
Put the tenant boundary in the authorization code path
The preventative path should accept the tenant derived by trusted routing or resource lookup and compare it with the session's selected tenant before checking a current membership. The following Go sketch keeps the risk engine and membership store behind generic interfaces. It deliberately fails closed when either dependency can't produce a decision.
package access
import "context"
type Principal struct {
SubjectID string
SelectedTenantID string
Authenticated bool
RecoveryRestricted bool
}
type Request struct {
ResourceTenantID string
Permission string
DeviceFingerprint string
}
type MembershipStore interface {
Allows(ctx context.Context, tenantID, subjectID, permission string) (bool, error)
}
type RiskPolicy interface {
RequiresStepUp(ctx context.Context, subjectID, fingerprint, permission string) (bool, error)
}
type Decision struct {
Allow bool
Status int
Reason string
}
func Authorize(
ctx context.Context,
p Principal,
r Request,
memberships MembershipStore,
risk RiskPolicy,
) Decision {
if !p.Authenticated {
return Decision{Status: 401, Reason: "authentication_required"}
}
if r.ResourceTenantID == "" || r.ResourceTenantID != p.SelectedTenantID {
return Decision{Status: 403, Reason: "tenant_mismatch"}
}
if p.RecoveryRestricted {
return Decision{Status: 403, Reason: "recovery_restricted"}
}
stepUp, err := risk.RequiresStepUp(
ctx, p.SubjectID, r.DeviceFingerprint, r.Permission,
)
if err != nil {
return Decision{Status: 503, Reason: "risk_decision_unavailable"}
}
if stepUp {
return Decision{Status: 403, Reason: "step_up_required"}
}
allowed, err := memberships.Allows(
ctx, r.ResourceTenantID, p.SubjectID, r.Permission,
)
if err != nil {
return Decision{Status: 503, Reason: "membership_decision_unavailable"}
}
if !allowed {
return Decision{Status: 403, Reason: "permission_denied"}
}
return Decision{Allow: true, Status: 200, Reason: "allowed"}
}
Production code should map internal reasons to a deliberately small set of external responses and send the detailed reason to access-controlled audit logs. The order matters too: the tenant comparison happens before a membership query, recovery restrictions apply before device risk can help, and an unavailable policy dependency never becomes an implicit allow.
Tests should cross the boundaries rather than merely cover happy paths. Use table-driven cases for two tenants sharing one subject, revoked membership with a valid session, recovery-restricted sessions on familiar and unfamiliar devices, stale membership versions, malformed tenant context, and policy timeouts. Then replay policy changes against sanitized historical decision inputs. A unit suite can prove branch behavior; replay tells you how a threshold change would affect real traffic distributions without pretending a fingerprint is stable identity.
Choose ownership by failure mode and on-call cost
The buy-versus-build question is not “Can this provider log users in?” It is “Where do tenant membership, recovery restrictions, policy evaluation, audit evidence, and revocation live, and who is paged when each one is unavailable?” Authentication can be managed while authorization remains in the application, or both can use dedicated components, but the contract between them must preserve the three separate decisions.
| Approach | Best fit | Main trade-off | Account-recovery check |
|---|---|---|---|
| Managed identity plus application policy | Small platform team that wants less authenticator operations | Application still owns tenant correctness and policy rollout | Confirm recovery context is available without turning it into a role |
| External policy engine | Many services need the same authorization semantics | Adds a decision dependency, policy deployment path, and cache-invalidation problem | Confirm recovery and device-risk attributes are explicit policy inputs |
| In-process authorization | Few services with one release cadence | Fast local decisions, but duplicated rules emerge as the system grows | Confirm every sensitive handler calls the same recovery gate |
| Self-hosted identity and policy stack | Regulatory or control requirements justify full ownership | Highest patching, capacity, and on-call burden | Exercise authenticator loss, session rotation, and membership revocation together |
Capacity planning should count authorization fan-out, not just login requests. For peak request rate R, a naïve design can create roughly R membership reads plus risk decisions; caching reduces dependency load but increases the revocation window. Measure decision latency by outcome, cache age at decision time, step-up rate, denied requests by stable reason, and membership-version lag. Set an SLO for the authorization decision separately from the page SLO, then test the fail-closed behavior under dependency saturation. Otherwise a fast page can conceal an access-control path that is denying everyone—or, worse, bypassing a check to stay fast.
Know when this design is the wrong size
The catch is operational weight. A single-tenant internal tool with centrally managed accounts may not need a tenant-membership layer; stick with the identity system's groups when there is one administrative boundary and the group lifecycle is authoritative. At the other extreme, a highly regulated publisher may require transaction-level policy evidence, dual control for support recovery, or a dedicated policy engine instead of application middleware.
Device fingerprinting is also unsuitable as the deciding factor for account recovery. If the application can't explain the signal's retention, false-positive handling, and step-up path, omit it from the allow decision until those controls exist. The safe default is plain: recovery restores an identity under defined restrictions, and only current tenant authorization restores application access.
Top comments (0)