LogoSkills

Payment & IAP Security Rules

Mandatory security rules for developing payment and in-app purchase (IAP) endpoints.

Mandatory security rules for developing payment and in-app purchase (IAP) endpoints.

Authentication Required#

All payment-related endpoints must set requireLogin => true.

// ✅ CORRECT
class IapPurchaseEndpoint extends Endpoint {
  @override
  bool get requireLogin => true;

  Future<void> verifyPurchase(Session session, ...) async { ... }
}

// ❌ WRONG: Processing payments without authentication
class IapPurchaseEndpoint extends Endpoint {
  // requireLogin not set = anyone can call!
  Future<void> verifyPurchase(Session session, ...) async { ... }
}

Server-Side User ID Verification#

Never trust the userId sent by the client. Always verify server-side with getCurrentUserInfo(session).

// ✅ CORRECT: Server-side user verification
Future<void> verifyPurchase(
  Session session,
  IapPurchaseRequest request,
) async {
  final userInfo = await getCurrentUserInfo(session);
  if (userInfo == null) throw AuthenticationException();

  // Use userInfo.id (ignore client userId)
  await _processPayment(session, userInfo.id!, request);
}

// ❌ WRONG: Trusting client userId
Future<void> verifyPurchase(
  Session session,
  IapPurchaseRequest request,
) async {
  // Using request.userId directly → allows payment manipulation for other users!
  await _processPayment(session, request.userId, request);
}

Reference: See serverpod-auth.md for getCurrentUserInfo() usage

Duplicate Payment Prevention#

Use equals() (No LIKE Queries)#

Use equals() for DB lookups. like() carries SQL injection risk and potential false positives from wildcard matching.

// ✅ CORRECT: Exact matching
final existing = await WalletTransaction.db.findFirstRow(
  session,
  where: (table) => table.description.equals(purchaseToken),
);

// ❌ WRONG: LIKE query (SQL injection and false positive risk)
final existing = await WalletTransaction.db.findFirstRow(
  session,
  where: (table) => table.description.like('%$purchaseToken%'),
);

Using a dedicated UNIQUE column for duplicate checks is the safest approach.

// ✅ BEST: Dedicated purchaseId UNIQUE column
final existing = await WalletTransaction.db.findFirstRow(
  session,
  where: (table) => table.purchaseId.equals(purchaseToken),
);

Unimplemented Feature Handling#

When platform-specific verification is not yet implemented, return a clear error instead of hard-coded tokens/responses.

// ✅ CORRECT: Return a clear error
Future<bool> verifyIosPurchase(String receiptData) async {
  throw UnimplementedError(
    'iOS App Store receipt verification not implemented. '
    'App Store Server API JWT token integration required.',
  );
}

// ❌ WRONG: Hard-coded success response
Future<bool> verifyIosPurchase(String receiptData) async {
  return true;  // Always succeeds without verification!
}

// ❌ WRONG: Hard-coded token
const _accessToken = 'ya29.hardcoded-token-here';

TossPayments Web Payment Security#

Security rules for when users are redirected from the iOS app to a web browser for direct payment via TossPayments, per Apple External Link policy.

Web Auth Token (WebAuthToken)#

Web payment pages authenticate using a temporary token generated from the app.

// Token characteristics
// - 10-minute validity (Duration(minutes: 10))
// - Single use (tracked via used flag)
// - UUID v4 format (Random.secure() based)
// - DB UNIQUE index prevents duplicates
StepToken HandlingMethod
Book detail page Read-only validation (not consumed) validateTokenReadOnly()
Payment success callback Validate + consume (used=true) validateAndConsumeToken()

Triple Amount Verification#

1. Client amount vs server-calculated amount → reject on mismatch
2. TossPayments API payment approval request
3. TossPayments response totalAmount vs server-calculated amount → reject on mismatch

userId Included in orderId#

// orderId format: WEBBOOK_{bookId}_{userId}_{timestamp}_{orderType}
// Cross-user attack prevention: verify userId extracted from orderId matches token userId

Store tossPaymentKey#

After payment approval, store tossPaymentKey in BookOrder for refund/audit tracking.

Platform-Specific Verification Strategy#

PlatformPayment MethodVerification MethodStatus
Android TossPayments Flutter Widget SDK (in-app) TossPayments API payment approval + amount cross-verification 🔧 Planned (Epic #2619)
iOS Apple External Link → web browser payment TossPayments API payment approval + amount cross-verification ✅ Implemented
Web dart:js_interop + TossPayments JS SDK v2 TossPayments API payment approval + amount cross-verification ✅ Implemented
Google Play IAP Google Play Billing Google Play Developer API (purchases.products.get) ✅ Implemented

TossPayments Flutter Widget SDK Limitations#

tosspayments_widget_sdk_flutter v2.1.2 supports Android/iOS only (no Flutter Web support). iOS cannot use the in-app payment widget due to Apple External Link policy.

Therefore, only Android uses the Widget SDK, while iOS/Web maintains the existing web payment approach as a hybrid strategy.

Android: Flutter Widget SDK (PaymentMethodWidget + AgreementWidget) → TossPayments API approval
iOS:     Apple External Link → web browser → TossPayments JS SDK → server callback
Web:     dart:js_interop → TossPayments JS SDK v2 → server callback

Google Play Verification Flow#

Client → purchaseToken → Server → Google API verification → credit top-up

iOS App Store Verification Flow (TODO)#

Client → receiptData → Server → App Store API (JWT) → credit top-up

Checklist#

When writing payment endpoints:

  • Set requireLogin => true
  • Verify user server-side with getCurrentUserInfo(session)
  • Ignore client-sent userId
  • Duplicate payment check: use equals() (no LIKE)
  • Recommend dedicated purchaseId column
  • Unimplemented features: return UnimplementedError (no hard-coding)
  • Amount/product verification: re-confirm price server-side
  • Web payments: single-use WebAuthToken consumption
  • Web payments: store tossPaymentKey (for refund/audit tracking)
  • backend/kobic_server/lib/src/feature/payments/endpoint/iap_purchase_endpoint.dart
  • backend/kobic_server/lib/src/feature/payments/service/iap_purchase_service.dart
  • backend/kobic_server/lib/src/feature/payments/helper/iap_verification_helper.dart
  • backend/kobic_server/lib/src/feature/payments/endpoint/web_payment_endpoint.dart
  • backend/kobic_server/lib/src/feature/payments/service/web_book_payment_service.dart
  • backend/kobic_server/lib/src/web/routes/store_book_route.dart
  • backend/kobic_server/lib/src/web/routes/store_payment_success_route.dart
  • backend/kobic_server/lib/src/web/routes/store_payment_fail_route.dart
  • serverpod-auth.md - getCurrentUserInfo() usage