Authentication Deep Dive

Multi-workspace routing, JWT lifecycle, BCrypt hashing, password expiry enforcement, account lockout, 2FA TOTP, external OAuth, OTP system, impersonation, SSO suspension gate, and session management.

This page dives deep into every authentication mechanism in SCRIPE — from multi-workspace login routing and JWT token issuance, through BCrypt password hashing, password expiry enforcement, and account lockout, to TOTP-based two-factor authentication, external OAuth providers, OTP codes, admin impersonation, SSO tenant suspension gates, and session management.

Multi-Workspace Login Routing

When an admin logs in from the platform domain without a pre-resolved tenant, the login handler executes a 3-case routing algorithm: Case A (tenant-scoped login with a provided tenantId), Case A' (explicit platform admin selection via the isPlatformAdmin flag — bypasses workspace discovery entirely), and Case B (workspace discovery — searches all tenants by email, returns a workspace picker if multiple matches are found). The isPlatformAdmin flag was introduced to prevent an infinite loop where selecting 'Platform Administration' from the workspace picker would re-trigger discovery.

Multi-Workspace Login Routing
POST /auth/login
Route Decision — Inspect tenantId & isPlatformAdmin
isPlatformAdmin
Case A': Platform Admin — isPlatformAdmin=true → TenantId=null lookup
Case B: Discovery — No tenantId → search all tenants
no results
0 Matches — Invalid credentials
1 Match — Direct auth for that tenant
N Matches — Return workspace list
Workspace Picker (Frontend)
Re-login with tenantId
Case A: Tenant-Scoped — tenantId present → strict isolation
Authenticate
FlagTypePurpose
tenantIdstring?Encrypted tenant ID from workspace selection or domain resolution. Triggers Case A.
isPlatformAdminbooleanSet to true when user selects 'Platform Administration' workspace. Triggers Case A' to prevent infinite discovery loop.

JWT Token Lifecycle

Access tokens are short-lived (15 minutes) JWT tokens signed with HMAC-SHA256. When an access token expires, the client uses the refresh token to obtain a new token pair. Refresh tokens are single-use and rotated on every use.

JWT Token Lifecycle
POST /auth/login
Validate Credentials — BCrypt verify + account checks
Password Expiry Check — ITenantPasswordValidator → MustChangePassword
not expired
Issue Token Pair — Access (15min) + Refresh (7d)
API Requests — Bearer token in Authorization header
after 15min
Access Token Expires
POST /auth/refresh
New Token Pair — Old refresh token revoked
POST /auth/logout — Revoke all tokens

JWT Token Structure

Access Token Payload (decoded)JSON
{
  "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "email": "admin@example.com",
  "given_name": "John",
  "family_name": "Doe",
  "role": "SuperAdmin",
  "permissions": [
    "admins.view", "admins.create", "admins.update",
    "users.view", "users.create", "roles.manage"
  ],
  "tenant_id": "f8e7d6c5-b4a3-2190-fedc-ba0987654321",
  "is_admin": "true",
  "impersonator_id": null,
  "iat": 1708444800,
  "exp": 1708445700,
  "iss": "scripe-api",
  "aud": "scripe-client"
}
ClaimPurposeSet By
subUser/Admin unique ID (Guid)JwtService
emailEmail addressJwtService
rolePrimary role nameJwtService
permissionsArray of permission slugsJwtService (from role)
tenant_idOwning tenant IDJwtService
is_adminAdmin vs User discriminatorJwtService
impersonator_idOriginal admin ID during impersonationImpersonateCommand
expExpiration (15 min default)JwtService

BCrypt Password Hashing

Passwords are hashed using BCrypt with a configurable work factor (default: 12). BCrypt is intentionally slow to resist brute-force attacks — each hash takes ~250ms, making mass password cracking impractical.

Password Hashing ImplementationC#
// Hash password with BCrypt (work factor = 12)
// ~250ms per hash — intentionally slow to resist brute force
var hashedPassword = BCrypt.Net.BCrypt.HashPassword(
    plainPassword,
    workFactor: 12  // 2^12 = 4,096 iterations
);

// Verify password during login
var isValid = BCrypt.Net.BCrypt.Verify(plainPassword, storedHash);
// Returns true/false — constant-time comparison prevents timing attacks
Work FactorIterationsTime per HashUse Case
101,024~65msDevelopment/testing
112,048~130msLow-security applications
12 (default)4,096~250msProduction (SCRIPE default)
138,192~500msHigh-security environments
1416,384~1sMaximum security (very slow)

Account Lockout

After 5 consecutive failed login attempts, the account is locked for 5 minutes. The counter resets on successful login. Admins can manually unlock accounts via the admin panel.

Account Lockout Flow
Login Attempt
Check Lockout Status
is locked
Account Locked — Return 423 Locked
Verify Password
wrong
Wrong Password — Increment FailedCount
FailedCount >= 5?
yes
Lock Account (5 min)
Login Success — Reset FailedCount
SettingDefault ValueConfigurable
Max Failed Attempts5Yes (appsettings)
Lockout Duration5 minutesYes (appsettings)
Lockout on First FailureNo—
Reset Counter on SuccessYes (automatic)—
Admin Can UnlockYes (POST /admins/{id}/unlock)—

Two-Factor Authentication (TOTP)

SCRIPE supports TOTP-based 2FA compatible with Google Authenticator, Authy, and Microsoft Authenticator. When enabled, users must enter a 6-digit time-based code after password verification.

2FA Setup & Verification Flow
POST /auth/2fa/enable — Generate TOTP secret + QR code
Login Attempt
Display QR Code — User scans with authenticator app
POST /auth/2fa/verify — Enter TOTP code or backup code
POST /auth/2fa/confirm — User enters 6-digit code to verify setup
Access Granted
Generate Backup Codes — 10 one-time-use recovery codes
2FA Active
TfaService — TOTP ImplementationC#
public class TfaService : ITfaService
{
    private const int SecretLength = 20;     // 160-bit secret
    private const int CodeLength = 6;        // 6-digit TOTP
    private const int TimeStep = 30;         // 30-second window
    private const int BackupCodeCount = 10;  // 10 backup codes

    public TfaSetupResult EnableTfa(string userId)
    {
        // 1. Generate random secret
        var secretBytes = RandomNumberGenerator.GetBytes(SecretLength);
        var secret = Base32Encoding.ToString(secretBytes);

        // 2. Generate QR code URI (otpauth:// format)
        var uri = $"otpauth://totp/SCRIPE:{userId}?secret={secret}&issuer=SCRIPE";

        // 3. Generate backup codes
        var backupCodes = Enumerable.Range(0, BackupCodeCount)
            .Select(_ => GenerateBackupCode())
            .ToList();

        return new TfaSetupResult(secret, uri, backupCodes);
    }

    public bool VerifyCode(string secret, string code)
    {
        // Validate TOTP with ±1 time step tolerance
        var totp = new Totp(Base32Encoding.ToBytes(secret),
            step: TimeStep, totpSize: CodeLength);

        return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
    }

    private static string GenerateBackupCode()
        => $"{Random.Shared.Next(10000000, 99999999)}"; // 8-digit
}

Password Expiry Enforcement

After credentials are validated and lockout checks pass, the login handler invokes ITenantPasswordValidator.IsPasswordExpiredAsync to check if the admin's password has exceeded the tenant's configured PasswordExpiryDays. If expired, the TokenResponse includes MustChangePassword = true, which forces the frontend to redirect the admin to the change-password page before granting dashboard access. This check uses the admin's PasswordLastChanged field against the tenant's security policy.

CheckSourceOutcome
PasswordExpiryDays > 0TenantSettingsFeature enabled for tenant
PasswordLastChanged + ExpiryDays < NowAdmin entityPassword is expired
MustChangePassword = trueTokenResponseFrontend forces redirect to change-password

External Authentication (OAuth)

SCRIPE integrates with Google, Facebook, Apple, and Microsoft OAuth providers. External tokens are validated server-side before creating or linking local accounts.

ProviderAuth TypeToken ValidationConfig Key
GoogleOAuth 2.0 / OpenID ConnectGoogle TokenInfo APIExternalAuth:Google:ClientId
FacebookOAuth 2.0Facebook Graph API /meExternalAuth:Facebook:AppId
AppleSign in with AppleApple public keys + JWTExternalAuth:Apple:ServiceId
MicrosoftOAuth 2.0 / MSALMicrosoft Graph APIExternalAuth:Microsoft:ClientId
ExternalAuthService — Provider ValidationC#
public class ExternalAuthService : IExternalAuthService
{
    public async Task<ExternalUserInfo?> ValidateTokenAsync(
        string provider, string token)
    {
        return provider.ToLower() switch
        {
            "google" => await ValidateGoogleTokenAsync(token),
            "facebook" => await ValidateFacebookTokenAsync(token),
            "apple" => await ValidateAppleTokenAsync(token),
            "microsoft" => await ValidateMicrosoftTokenAsync(token),
            _ => throw new ArgumentException($"Unknown provider: {provider}")
        };
    }
}

// Returned user info for account linking/creation
public record ExternalUserInfo(
    string ProviderId,     // Provider's unique user ID
    string Email,
    string? FirstName,
    string? LastName,
    string? AvatarUrl,
    string Provider        // "google", "facebook", etc.
);

OTP System (One-Time Passwords)

OTP codes are used for email verification, phone verification, and password reset flows. Codes are 6-digit, cryptographically random, stored as BCrypt hashes, and expire after 15 minutes with a maximum of 3 verification attempts.

OTP PurposeDeliveryLengthExpiryMax Attempts
Email VerificationEmail6 digits15 minutes3
Phone VerificationSMS6 digits10 minutes3
Password ResetEmail6 digits15 minutes3
Rate LimitPer IP—60 seconds cooldown5 per hour
OtpService — Code Generation & VerificationC#
public class OtpService : IOtpService
{
    public async Task<string> GenerateAsync(
        string userId, OtpPurpose purpose, CancellationToken ct)
    {
        // 1. Invalidate any existing OTP for this user+purpose
        await _repository.InvalidateExistingAsync(userId, purpose, ct);

        // 2. Generate cryptographically random 6-digit code
        var code = RandomNumberGenerator.GetInt32(100000, 999999).ToString();

        // 3. Store hashed code (never store plaintext)
        var otpCode = new OtpCode
        {
            UserId = userId,
            Purpose = purpose,
            CodeHash = BCrypt.Net.BCrypt.HashPassword(code),
            ExpiresAt = DateTime.UtcNow.AddMinutes(15),
            RemainingAttempts = 3
        };

        await _repository.AddAsync(otpCode, ct);
        return code; // Return plaintext to send via email/SMS
    }

    public async Task<bool> VerifyAsync(
        string userId, string code, OtpPurpose purpose, CancellationToken ct)
    {
        var otp = await _repository.GetLatestAsync(userId, purpose, ct);
        if (otp is null || otp.ExpiresAt < DateTime.UtcNow) return false;
        if (otp.RemainingAttempts <= 0) return false;

        var isValid = BCrypt.Net.BCrypt.Verify(code, otp.CodeHash);
        if (!isValid)
        {
            otp.RemainingAttempts--;
            await _repository.SaveChangesAsync(ct);
            return false;
        }

        // Mark as used
        otp.UsedAt = DateTime.UtcNow;
        await _repository.SaveChangesAsync(ct);
        return true;
    }
}

Admin Impersonation

SuperAdmins can impersonate other admins to troubleshoot issues. An impersonation token carries the target admin's claims plus an impersonator_id claim. All actions during impersonation are audited with the original admin's identity.

Admin Impersonation Flow
SuperAdmin
POST /admins/{id}/impersonate
Security Checks — Can't impersonate protected/superior admins
allowed
Issue Impersonation Token — Claims of target + impersonator_id claim
Act as Target Admin
POST /admins/stop-impersonation
Restore Original Token

Session Management

SCRIPE uses a stateless JWT-based session model. Access tokens are held in client memory (never localStorage), refresh tokens are stored as HttpOnly secure cookies or in the database, and 2FA session tokens are temporary in-memory tokens valid for 5 minutes.

TokenStorageLifetimeRotation
Access TokenClient memory (not localStorage)15 minutesOn refresh
Refresh TokenHttpOnly secure cookie or DB7 daysSingle-use (rotate on use)
2FA Session TokenTemporary in-memory5 minutesOne-time use
Impersonation TokenClient (replaces access)Same as accessOn stop-impersonation

SSO Tenant Suspension Gate

The ExternalLoginCommandHandler now includes a tenant suspension security gate. Before issuing a JWT after SSO/OIDC authentication, the handler checks the admin's tenant status. If the tenant is Suspended or Cancelled, the login is rejected with a localized error — preventing deactivated users from bypassing the standard login checks via SSO. This gate runs after token validation and account linking but before JWT issuance.