Authentication & Transaction Signing Guide
Overview
Wafra implements a hybrid authentication system that combines traditional password-based authentication with passkey (WebAuthn) technology, integrated with Gnosis Safe for wallet management. This document describes the complete flow from user signup to transaction signing, including unified device management with approval and removal capabilities.
Table of Contents
- Architecture Overview
- User Signup Flow
- Authentication Methods
- Multi-Device Passkey Management
- Device Approval Flow
- Secure Device Removal
- Gnosis Safe Integration
- Transaction Signing Flow
- Replace Backup Flow
- Key Components
- Security Considerations
Architecture Overview
The system uses a multi-layered approach:
- Frontend: React Native app with Expo Router
- Backend: Express.js API with Prisma ORM
- Database: PostgreSQL with comprehensive user/wallet schema
- Blockchain: Base network with Gnosis Safe for wallet management
- Authentication: JWT tokens + WebAuthn passkeys with multi-device support
Key Technologies
- Gnosis Safe: Smart contract wallet for secure multi-signature operations
- WebAuthn/Passkeys: FIDO2 standard for passwordless authentication with multi-device support
- Firebase: Used for phone authentication (configured in
apps/app/lib/firebase.ts) - Prisma: Type-safe database ORM
User Signup Flow
The signup process follows a multi-step onboarding flow with state tracking:
1. Initial Registration
File: apps/app/app/onboarding/index.tsx
// User provides phone number, password, and country
const response = await exec("/auth/signup", {
method: "POST",
body: JSON.stringify({
phone: phoneNumber.number.toString(),
country,
password,
}),
});Backend: apps/server/src/api/routes/auth.ts:119-186
- Validates phone number using
libphonenumber-js - Hashes password with bcrypt (10 rounds)
- Creates user record with
SignupState.INITIAL - Returns JWT token and redirects to next onboarding step
Attention: The JWT is a session. Routes need to deal with the fact that the user is not completely created. TODO handle this better.
2. Signup States
Database Schema: apps/server/prisma/schema.prisma:18-25
enum SignupState {
INITIAL
PASSKEY
PHONE
WALLET
PROFILE
COMPLETED
}The system tracks user progress through these states and redirects users to the appropriate onboarding step when they sign in.
3. Phone Verification
File: apps/app/app/onboarding/phone.tsx
Users receive SMS verification (implementation details in phone.tsx).
4. Passkey Setup
File: apps/app/app/onboarding/passkey.tsx
Users register their first WebAuthn passkey for secure authentication. This first passkey is automatically approved and becomes the primary authentication method.
const handleSetupPasskey = async () => {
try {
// First passkey is automatically approved during onboarding
await registerPasskey();
router.push("/onboarding/profile");
} catch (err: any) {
setError(err.message || "Failed to set up passkey");
}
};5. Wallet Creation
Wallets are now created asynchronously during user signup through the triggerAsyncSafeDeployment service. The wallet creation happens in the background and doesn’t require a separate onboarding step.
File: apps/server/src/services/auth/authentication.service.ts
The signup process triggers wallet creation:
// Trigger async Safe deployment after successful user creation
await this.safeService.triggerAsyncSafeDeployment(newUser);Multi-Device Passkey Management
Overview
Wafra supports multiple passkeys per user account, allowing users to authenticate from different devices (phones, tablets, computers). The system implements a secure approval flow for new devices while maintaining the ability to manage and remove devices.
Database Schema
File: apps/server/prisma/schema.prisma
model Passkey {
id String @id @default(cuid())
userId String
credentialId String @unique
publicKey String
counter Int @default(0)
deviceType String?
deviceName String?
approved Boolean @default(false)
createdAt DateTime @default(now())
lastUsedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceApprovalRequests DeviceApprovalRequest[] @relation("RequestingPasskey")
}
model DeviceApprovalRequest {
id String @id @default(cuid())
userId String
requestingPasskeyId String
deviceName String
deviceType String
approved Boolean?
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
requestingPasskey Passkey @relation("RequestingPasskey", fields: [requestingPasskeyId], references: [id], onDelete: Cascade)
}Adding Additional Devices
File: apps/app/app/auth/device-setup.tsx
Users can add additional devices through the device setup screen:
const handleAddPasskey = async () => {
try {
await registerPasskey({
deviceName: deviceName.trim(),
deviceType: Platform.OS,
});
Alert.alert(
"Device Added Successfully",
`Your device "${deviceName}" has been added but needs approval from another device.`,
[
{
text: "Check Status",
onPress: () => router.push("/auth/approval-status"),
},
]
);
} catch (error: any) {
setError(error.message || "Failed to set up device");
}
};Backend Processing: apps/server/src/api/controllers/passkey.ts:106-137
// Check if this is the first passkey for the user
const existingPasskeys = await prisma.passkey.findMany({
where: { userId: user.id },
});
const isFirstPasskey = existingPasskeys.length === 0;
const passkey = await prisma.passkey.create({
data: {
userId: user.id,
credentialId: credential.rawId,
publicKey: credential.response.publicKey!,
counter,
deviceType: deviceType || "unknown",
deviceName: deviceName || "Unknown Device",
approved: isFirstPasskey, // First passkey is automatically approved
},
});
// If this is not the first passkey, create an approval request
if (!isFirstPasskey) {
await prisma.deviceApprovalRequest.create({
data: {
userId: user.id,
requestingPasskeyId: passkey.id,
deviceName: deviceName || "Unknown Device",
deviceType: deviceType || "unknown",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
},
});
}Device Approval Flow
Pending Request Notification
File: apps/app/components/dashboard/Header.tsx
The header displays a notification badge when there are pending device approval requests:
const { showApprovalModal, pendingRequestsCount } = useDeviceApproval();
{
pendingRequestsCount > 0 && (
<View className="absolute -top-1 -right-1 bg-red-500 rounded-full w-4 h-4">
<Text className="text-white text-xs font-bold">
{pendingRequestsCount}
</Text>
</View>
);
}Device Management Interface
File: apps/app/app/devices/index.tsx
Users can view and manage their registered devices:
const { data: passkeys } = useQuery({
queryKey: ["passkeys"],
queryFn: () => exec("/passkeys"),
});
const { data: pendingRequests } = useQuery({
queryKey: ["passkeys", "pending"],
queryFn: () => exec("/passkeys/approval/pending"),
});Approval Process
File: apps/app/app/devices/[id].tsx
Users can approve or reject pending device requests:
const handleApprove = async () => {
try {
await exec("/passkeys/approval/approve", {
method: "POST",
body: JSON.stringify({
requestId: request.id,
approved: true,
}),
});
Alert.alert(
"Device Approved",
"The device has been approved successfully."
);
router.back();
} catch (error: any) {
setError(error.message || "Failed to approve device");
}
};Secure Device Removal
Removal Process
File: apps/app/app/devices/[id].tsx
Users can remove devices with multiple security checks:
const handleRemoveDevice = async () => {
try {
// Requires recent session (≤5 minutes)
await exec("/passkeys/remove", {
method: "POST",
body: JSON.stringify({
passkeyId: passkey.id,
}),
});
Alert.alert("Device Removed", "The device has been removed successfully.");
router.back();
} catch (error: any) {
setError(error.message || "Failed to remove device");
}
};Backend Security Checks: apps/server/src/api/controllers/passkey.ts:200-250
// Security checks for device removal
const sessionAge = Date.now() - ctx.session.iat * 1000;
const maxSessionAge = 5 * 60 * 1000; // 5 minutes
if (sessionAge > maxSessionAge) {
throw new AppError(400, "Session too old for device removal");
}
// Cannot remove current session's passkey
if (passkey.id === ctx.session.passkeyId) {
throw new AppError(400, "Cannot remove current device");
}
// Cannot remove last approved device
const approvedPasskeys = await prisma.passkey.findMany({
where: { userId: user.id, approved: true },
});
if (approvedPasskeys.length <= 1) {
throw new AppError(400, "Cannot remove last approved device");
}Gnosis Safe Integration
Wallet Creation
File: apps/server/src/services/safe/safe.service.ts
Wallets are created as Gnosis Safe multi-signature wallets:
async triggerAsyncSafeDeployment(user: User): Promise<void> {
const safeAddress = await this.deploySafe(user);
await prisma.user.update({
where: { id: user.id },
data: { walletAddress: safeAddress },
});
}Multi-Signature Structure
The Safe wallet requires 2 of 3 signatures:
- User Passkey: Hardware-backed authentication
- Server Controller: Automated server signature
- Backup Key: Encrypted backup for recovery
Transaction Signing Flow
Client-Side Preparation
File: apps/app/lib/transactions/signature-service.ts
Users prepare transactions with their passkey:
const prepareTransaction = async (transaction: Transaction) => {
const passkey = await getPasskey();
const safeTxHash = await prepareSafeTransaction(transaction);
const signature = await signWithPasskey(safeTxHash, passkey);
return {
safeTxHash,
signature,
signerAddress: passkey.address,
};
};Server-Side Verification
File: apps/server/src/api/controllers/wallet.ts
The server verifies and co-signs transactions:
// Verify user signature
const isValidSignature = await verifyPasskeySignature(
safeTxHash,
signature,
user.passkey.publicKey
);
if (!isValidSignature) {
throw new AppError(400, "Invalid signature");
}
// Add server signature
const serverSignature = await this.safeService.signTransaction(safeTxHash);
// Execute transaction
const transactionHash = await this.safeService.executeTransaction(safeTxHash, [
signature,
serverSignature,
]);Replace Backup Flow
Backup Replacement Process
File: apps/app/app/profile/backup.tsx
Users can replace their backup key with enhanced security:
const handleSubmit = async () => {
// Step 1: Authenticate user
await login({ phone: account!.phone, password });
// Step 2: Generate new backup wallet
const { publicKey, encryptedPrivateKey } = await generateWallet(password);
// Step 3: Get current wallet info
const { backupAddress: oldBackupAddress, walletAddress } =
await exec("/wallet");
// Step 4: Prepare transaction with passkey
const passkey = await getPasskey();
const { safeTxHash, signature, signerAddress, nonce } =
await prepareSwapBackupOwner(
walletAddress,
oldBackupAddress,
publicKey,
passkey
);
// Step 5: Submit to server
const { transactionHash } = await exec("/wallet/replace-backup", {
method: "POST",
body: JSON.stringify({
safeTxHash,
signerAddress,
newBackupAddress: ethers.getAddress(publicKey),
encryptedBackup: encryptedPrivateKey,
signature,
nonce,
}),
});
};Backend Processing
File: apps/server/src/api/controllers/wallet.ts
The server:
- Validates the incoming transaction data
- Recreates the transaction locally for verification
- Adds the server’s signature
- Executes the transaction on-chain
- Updates the database with new backup information
Key Components
Frontend Components
useAuth(apps/app/hooks/useAuth.tsx): Central authentication managementusePasskey(apps/app/hooks/usePasskey.ts): WebAuthn/passkey operations including device management- Device Management Pages:
apps/app/app/devices/index.tsx: Device list and management interfaceapps/app/app/devices/[id].tsx: Secure device removal with authentication
- Device Approval System:
apps/app/providers/DeviceApprovalProvider.tsx: Context for device approval managementapps/app/app/auth/device-setup.tsx: New device registration interfaceapps/app/app/auth/approval-status.tsx: Approval status tracking
- Header Component (
apps/app/components/dashboard/Header.tsx): Navigation with device management access and approval notifications
Backend Components
- Auth Routes (
apps/server/src/api/routes/auth.ts): Authentication endpoints - Passkey Routes (
apps/server/src/api/routes/passkey.ts): Device management and approval endpoints - Passkey Controllers (
apps/server/src/api/controllers/passkey.ts):- Device registration with approval flow
- Secure device removal with multiple security checks
- Device approval/rejection handling
- Pending approval request management
- Wallet Controllers (
apps/server/src/api/controllers/wallet.ts): Wallet operation handlers - Safe Library (
apps/server/src/lib/safe.ts): Gnosis Safe integration - Passkey Library (
apps/server/src/lib/passkey.ts): WebAuthn verification utilities
Database Models
User Model (apps/server/prisma/schema.prisma:27-45):
model User {
id String @id @default(cuid())
phone String @unique
password String
name String?
walletAddress String? @unique
backupAddress String? @unique
encryptedBackup String?
phoneVerified DateTime?
onboardingCompleted Boolean @default(false)
signupState SignupState @default(INITIAL)
country String
currency String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
passkeys Passkey[]
deviceApprovalRequests DeviceApprovalRequest[]
transactions Transaction[]
}Security Considerations
Multi-Layer Security
- Password Protection: User passwords are hashed with bcrypt
- JWT Security: Short-lived tokens (5 days) with automatic refresh
- Passkey Security: FIDO2/WebAuthn provides phishing-resistant authentication
- Multi-Signature: Gnosis Safe requires both user and server signatures
- Encrypted Backups: Private keys are encrypted with user passwords
Transaction Security
- Hash Verification: Server verifies transaction hashes match client preparation
- Signature Validation: WebAuthn signatures are cryptographically verified
- Nonce Management: Prevents replay attacks
- On-Chain Execution: Final transaction execution on Base network
Best Practices
- All sensitive operations require user authentication
- Passkeys provide hardware-backed security where available
- Server maintains minimal privileges (can’t execute without user signature)
- Database stores minimal sensitive information
- All API endpoints are protected with JWT authentication
Authentication Methods
1. Password Authentication
Implementation: apps/app/hooks/useAuth.tsx:125-143
const login = async (credentials: { phone: string; password: string }) => {
const response = await exec("/auth/signin", {
method: "POST",
body: JSON.stringify(credentials),
});
return response;
};Backend Verification: apps/server/src/api/routes/auth.ts:28-74
- Looks up user by phone number
- Verifies password with bcrypt
- Issues JWT token valid for 5 days
- Redirects based on signup state
2. Passkey Authentication
Implementation: apps/app/hooks/useAuth.tsx:145-169
const passkey = async () => {
// Get authentication options from server
const { challenge, challengeId } = await exec("/auth/passkey");
// Start WebAuthn authentication
const credential = await startAuthentication({
optionsJSON: challenge,
});
// Verify with server
await exec("/auth/passkey", {
method: "POST",
body: JSON.stringify({ credential, challengeId }),
});
};WebAuthn Flow: apps/app/hooks/usePasskey.ts
The passkey system handles:
- Registration of new passkeys with device approval flow
- Authentication with approved passkeys only
- Multi-device credential management and storage
- Secure device removal with authentication requirements
3. Session Management
JWT Implementation: apps/server/src/api/routes/auth.ts:18-26
function getToken(user: User) {
return jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "5d" });
}Frontend Storage: Uses AsyncStorage for token persistence with automatic refresh on API calls. If the API returns a new token it gets refreshed for any request.
Session Security: Tracks session creation time (iat) for age validation in sensitive operations like device removal.
External References
- Gnosis Safe Documentation
- WebAuthn Specification
- Safe Protocol Kit
- Base Network Documentation
- FIDO Alliance
This documentation covers the complete authentication and transaction signing flows in the Wafra application, showing how traditional and modern authentication methods are combined with Gnosis Safe for secure wallet operations.