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

  1. Architecture Overview
  2. User Signup Flow
  3. Authentication Methods
  4. Multi-Device Passkey Management
  5. Device Approval Flow
  6. Secure Device Removal
  7. Gnosis Safe Integration
  8. Transaction Signing Flow
  9. Replace Backup Flow
  10. Key Components
  11. 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:

  1. User Passkey: Hardware-backed authentication
  2. Server Controller: Automated server signature
  3. 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:

  1. Validates the incoming transaction data
  2. Recreates the transaction locally for verification
  3. Adds the server’s signature
  4. Executes the transaction on-chain
  5. Updates the database with new backup information

Key Components

Frontend Components

Backend Components

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

  1. Password Protection: User passwords are hashed with bcrypt
  2. JWT Security: Short-lived tokens (5 days) with automatic refresh
  3. Passkey Security: FIDO2/WebAuthn provides phishing-resistant authentication
  4. Multi-Signature: Gnosis Safe requires both user and server signatures
  5. Encrypted Backups: Private keys are encrypted with user passwords

Transaction Security

  1. Hash Verification: Server verifies transaction hashes match client preparation
  2. Signature Validation: WebAuthn signatures are cryptographically verified
  3. Nonce Management: Prevents replay attacks
  4. 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

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.