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.
| Flag | Type | Purpose |
|---|---|---|
| tenantId | string? | Encrypted tenant ID from workspace selection or domain resolution. Triggers Case A. |
| isPlatformAdmin | boolean | Set 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 Structure
{
"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"
}| Claim | Purpose | Set By |
|---|---|---|
| sub | User/Admin unique ID (Guid) | JwtService |
| Email address | JwtService | |
| role | Primary role name | JwtService |
| permissions | Array of permission slugs | JwtService (from role) |
| tenant_id | Owning tenant ID | JwtService |
| is_admin | Admin vs User discriminator | JwtService |
| impersonator_id | Original admin ID during impersonation | ImpersonateCommand |
| exp | Expiration (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.
// 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 Factor | Iterations | Time per Hash | Use Case |
|---|---|---|---|
| 10 | 1,024 | ~65ms | Development/testing |
| 11 | 2,048 | ~130ms | Low-security applications |
| 12 (default) | 4,096 | ~250ms | Production (SCRIPE default) |
| 13 | 8,192 | ~500ms | High-security environments |
| 14 | 16,384 | ~1s | Maximum 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.
| Setting | Default Value | Configurable |
|---|---|---|
| Max Failed Attempts | 5 | Yes (appsettings) |
| Lockout Duration | 5 minutes | Yes (appsettings) |
| Lockout on First Failure | No | — |
| Reset Counter on Success | Yes (automatic) | — |
| Admin Can Unlock | Yes (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.
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.
| Check | Source | Outcome |
|---|---|---|
| PasswordExpiryDays > 0 | TenantSettings | Feature enabled for tenant |
| PasswordLastChanged + ExpiryDays < Now | Admin entity | Password is expired |
| MustChangePassword = true | TokenResponse | Frontend 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.
| Provider | Auth Type | Token Validation | Config Key |
|---|---|---|---|
| OAuth 2.0 / OpenID Connect | Google TokenInfo API | ExternalAuth:Google:ClientId | |
| OAuth 2.0 | Facebook Graph API /me | ExternalAuth:Facebook:AppId | |
| Apple | Sign in with Apple | Apple public keys + JWT | ExternalAuth:Apple:ServiceId |
| Microsoft | OAuth 2.0 / MSAL | Microsoft Graph API | ExternalAuth:Microsoft:ClientId |
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 Purpose | Delivery | Length | Expiry | Max Attempts |
|---|---|---|---|---|
| Email Verification | 6 digits | 15 minutes | 3 | |
| Phone Verification | SMS | 6 digits | 10 minutes | 3 |
| Password Reset | 6 digits | 15 minutes | 3 | |
| Rate Limit | Per IP | — | 60 seconds cooldown | 5 per hour |
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.
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.
| Token | Storage | Lifetime | Rotation |
|---|---|---|---|
| Access Token | Client memory (not localStorage) | 15 minutes | On refresh |
| Refresh Token | HttpOnly secure cookie or DB | 7 days | Single-use (rotate on use) |
| 2FA Session Token | Temporary in-memory | 5 minutes | One-time use |
| Impersonation Token | Client (replaces access) | Same as access | On 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.