TL;DR: The SaaS began with one effective role: any authenticated teammate could access billing and invite users. I replaced that model with explicit organization roles, support access that never creates customer membership, and an MFA flow designed to avoid password only fallbacks.
Authentication answers who is this? Authorization answers what may they do here, right now? Treating those as the same problem works briefly. It becomes expensive once billing, support, and multiple customer organizations enter the system.
This is how I separated those concerns in a B2B SaaS that runs the proposal-to-billing workflow.
The Starting Point: Authenticated Meant Trusted
The original product had one practical role: authenticated member. A valid session could reach billing and invite users. Support staff were stored as members of customer organizations, so they appeared in Team lists. Staff sign-in was password-only.
None of those shortcuts is unusual in an early product. Together, though, they leave no meaningful boundary around financial actions, support access, or account recovery.
I set four non-negotiable constraints before adding a guard:
- Every organization has exactly one OWNER.
- Support can operate in an organization without becoming a member of it.
- MFA must have a recovery story—not just an enrollment screen.
- Authorization can be disabled during rollout without a deployment.
Start With a Small Model You Can Explain
The application uses one role per user:
| Role | Organization membership | Responsibility |
|---|---|---|
| SUPERADMIN | None | Internal support access only |
| OWNER | Required | Billing, team administration, ownership transfer |
| ADMIN | Required | Billing and team administration |
| USER | Required | Normal product access |
New organizations create an OWNER. Invites may create ADMIN or USER accounts, but never an OWNER or SUPERADMIN. That removes a surprising amount of ambiguity: ownership changes through one explicit operation, rather than through an invite or a generic role edit.
The Nest backend maps capabilities to roles in one permission matrix:
billing:read / billing:write → SUPERADMIN, OWNER, ADMIN
team:invite / team:manage → SUPERADMIN, OWNER, ADMIN
team:transfer → SUPERADMIN, OWNER
support:access → SUPERADMIN
The matrix is necessary, but it is not sufficient. A route guard can decide whether a caller reaches a handler; it cannot express every relationship inside a mutation. For example, an ADMIN must not remove, deactivate, or change the role of the OWNER. Those target-user rules belong in the service that performs the mutation.
Ownership transfer is therefore transactional: promote the recipient, demote the current OWNER, and move the Stripe billing anchor in the same Prisma transaction. If one step fails, the organization keeps its previous state.
Put Authorization Where Change Can Be Seen
Amazon Cognito remains the identity provider: it proves the user authenticated successfully. The application database supplies the current organization and role for each request.
Cognito can customize ID and access-token claims through a pre-token-generation trigger. That is useful in some architectures. It still creates a snapshot at token issuance, so it was the wrong source of truth for changes that must take effect immediately—such as an ownership transfer, a suspended account, or a support reset. The request-time database lookup trades a small read for current authorization decisions.
Valid Cognito token
↓
Auth guard verifies identity
↓
Application loads role + organization context
↓
Permission matrix and domain rules authorize the operation
This boundary is deliberate: Cognito owns identity; the product owns its changing business rules.
Support Access Is a Session, Not Membership
The tempting shortcut is to add support users to every customer organization they inspect. That leaks internal identities into customer Team lists and makes it difficult to answer a basic question: is this person a customer member or acting as support?
Instead, SUPERADMIN users have no organizationId. They choose an organization from a support panel and create an explicit, time-boxed act-as session:
SUPERADMIN chooses an organization
↓
POST /act-as { organizationId }
↓
Server records an expiring support session
↓
Signed act-as token identifies the selected organization
↓
Each request verifies the token and the server-side session
While that session is valid, the support user receives OWNER-equivalent access only for the selected organization. Without it, they have no effective organization context for organization-scoped operations.
The session uses a sliding 60-minute TTL, refreshed by activity. The interface keeps the state visible with an organization banner, remaining time, and an Exit action. Visibility is a security control here: support should never accidentally believe it is operating in its own account while acting in a customer context.
SUPERADMIN provisioning is also separate from public registration. A dedicated, restricted endpoint creates the Cognito identity and the application user without an organization membership. Organizations are then created normally and receive an invited OWNER.
MFA Must Include Recovery From Day One
Mandatory MFA without a recovery path is not a security feature—it is a future support queue.
The login flow withholds application tokens after password verification. The server stores the pending Cognito tokens in a short-lived MfaLoginSession; the client receives only a challenge identifier. It receives full tokens after a second factor succeeds.
Password verified
↓
No enrolled method → MFA_ENROLL
Enrolled method → MFA_CHALLENGE
↓
Verify a second factor
↓
Release tokens and enter the dashboard
The supported methods are deliberately limited:
| Method | Purpose | Safeguards |
|---|---|---|
| Email OTP | Low-friction initial enrollment | 6-digit, hashed, 10-minute TTL, five attempts, resend limit |
| TOTP | Stronger authenticator-app option | Verified through Cognito |
| Backup codes | Account recovery | Ten single-use codes, shown once and stored hashed |
Email OTP makes first enrollment practical; it is not treated as equivalent to a phishing-resistant factor. TOTP and backup codes provide a path for users who need stronger authentication or lose access to email. The system never allows someone to remove their final enrolled method. If an email change removes their only Email OTP method, the next login returns to enrollment rather than silently granting a password-only session.
I kept the user-pool MFA setting optional and enforced the “at least one method” rule in the application. That lets Cognito handle TOTP verification while the application owns email OTP, backup codes, enrollment, and recovery. It also keeps the rollout switchable.
Why own the email-code flow?
Cognito supports email MFA. In this product, the application-owned email-code flow gave the team control over challenge storage, expiry, retries, backup-code recovery, and the separation from the existing password-recovery flow. That is a product decision, not a claim that Cognito cannot send email OTPs.
Roll Out the Foundation Before the Hard Cut
The order matters more than the individual migrations:
- Add the schema, permission matrix, and role guard behind a feature flag.
- Add act-as sessions, the support picker, and the persistent banner.
- Add MFA enrollment, challenge, backup codes, and reset workflows.
- Backfill exactly one OWNER per organization, then enable enforcement.
The backfill begins with a dry run that writes a CSV. It chooses the oldest eligible non-support member when the data is unambiguous and marks the rest for review. Automation should surface uncertainty, not guess who owns a customer account.
The RBAC kill switch is the rollback mechanism during rollout. MFA is intentionally treated differently: the recovery path and reset endpoint must be complete before the hard cut, because a security control that is easy to bypass is not really enforced.
The Decisions That Matter
Three choices made the system easier to reason about:
- Database role over token-only authorization. Token claims can be customized, but the application needs current state at the moment it makes a sensitive decision.
- Act-as over membership. Support access is visible, scoped, revocable, and absent from customer Team data.
- Recovery over a false choice between security and usability. MFA enrollment, backup codes, and reset all lead back to a verified second factor—not a password-only exception.
The Role enum was the easy part. The real work was defining what must remain true when people change roles, work across organizations, lose a second factor, or need support. Identity providers authenticate people well. Product authorization needs the business context only the application can own.