@@ -169,24 +169,22 @@ $clientPaymentData = CollectedClientPaymentData::create($paymentData);
169169
170170### Using the Payment Controller
171171
172- The Stimulus payment controller simplifies SPC integration on the client side:
172+ The Stimulus payment controller simplifies SPC integration on the client side.
173+
174+ ** SECURITY NOTICE:** For security reasons, payment details (amount, payee, etc.) are NOT passed via HTML attributes, as these can be tampered with by the client. Instead, you pass a secure ` transaction-id ` , and the server fetches the actual payment details from its database.
173175
174176``` html
175177<form data-controller =" webauthn--payment"
176178 data-action =" submit->webauthn--payment#confirmPayment"
177179 data-webauthn--payment-options-url-value =" /api/payment/options"
178180 data-webauthn--payment-result-url-value =" /api/payment/verify"
179- data-webauthn--payment-payee-name-value =" Merchant Store"
180- data-webauthn--payment-payee-origin-value =" https://merchant.example.com"
181- data-webauthn--payment-amount-value =" 99.99"
182- data-webauthn--payment-currency-value =" USD"
183- data-webauthn--payment-instrument-display-name-value =" Visa •••• 1234"
184- data-webauthn--payment-instrument-icon-value =" https://example.com/visa-icon.png" >
181+ data-webauthn--payment-transaction-id-value =" txn_abc123def456" >
185182
186183 <h2 >Confirm Payment</h2 >
187- <p >Amount: <strong >$99.99 USD</strong ></p >
188- <p >Merchant: <strong >Merchant Store</strong ></p >
189- <p >Payment Method: <strong >Visa •••• 1234</strong ></p >
184+ <!-- Display payment details from server-side data -->
185+ <p >Amount: <strong ><?= htmlspecialchars($transaction->getFormattedAmount()) ?></strong ></p >
186+ <p >Merchant: <strong ><?= htmlspecialchars($transaction->getPayeeName()) ?></strong ></p >
187+ <p >Payment Method: <strong ><?= htmlspecialchars($transaction->getInstrumentDisplay()) ?></strong ></p >
190188
191189 <input type =" hidden" data-webauthn--payment-target =" result" >
192190 <button type =" submit" >Confirm Payment</button >
@@ -199,28 +197,26 @@ The Stimulus payment controller simplifies SPC integration on the client side:
199197| --------| ------| ---------| -------------|
200198| ` optionsUrl ` | String | ` /payment/options ` | URL to fetch payment options |
201199| ` resultUrl ` | String | ` /payment/verify ` | URL to verify payment result |
202- | ` payeeName ` | String | - | Name of the payee (merchant) |
203- | ` payeeOrigin ` | String | - | Origin of the payee |
204- | ` amount ` | String | - | Payment amount |
205- | ` currency ` | String | ` USD ` | Payment currency (ISO 4217) |
206- | ` instrumentDisplayName ` | String | - | Display name for payment instrument |
207- | ` instrumentIcon ` | String | - | Icon URL for payment instrument |
200+ | ` transactionId ` | String | - | ** Secure transaction ID** (server fetches payment details) |
208201| ` submitViaForm ` | Boolean | ` false ` | Submit credential via form instead of API |
209202| ` successRedirectUri ` | String | - | URI to redirect to on success |
210203
204+ ** Security Note:** Payment details (amount, payee, merchant) are ** intentionally NOT configurable** via HTML attributes to prevent client-side tampering. The server must fetch these from its database using the ` transactionId ` .
205+
211206### Controller Events
212207
213208The payment controller dispatches several custom events:
214209
215210``` javascript
216211// Connection event
217212document .addEventListener (' webauthn:payment:connect' , (event ) => {
218- console .log (' Payment controller connected' , event .detail );
213+ console .log (' Payment controller connected' );
214+ console .log (' Transaction ID:' , event .detail .transactionId );
219215});
220216
221217// Options request/response events
222218document .addEventListener (' webauthn:payment:options:request' , (event ) => {
223- console .log (' Requesting payment options' , event .detail .data );
219+ console .log (' Requesting payment options for transaction: ' , event .detail .data . transactionId );
224220});
225221
226222document .addEventListener (' webauthn:payment:options:success' , (event ) => {
@@ -283,22 +279,40 @@ class PaymentController
283279 {
284280 $data = json_decode($request->getContent(), true);
285281
286- // Create payment extension
282+ // SECURITY: Fetch payment details from database using transaction ID
283+ // This prevents client-side tampering of amounts/payee
284+ $transactionId = $data['transactionId'] ?? null;
285+ if (!$transactionId) {
286+ return new JsonResponse(['error' => 'Transaction ID required'], 400);
287+ }
288+
289+ // Get transaction from secure database
290+ $transaction = $this->transactionRepository->findOneBy(['id' => $transactionId]);
291+ if (!$transaction || $transaction->getUserId() !== $this->getUser()->getId()) {
292+ return new JsonResponse(['error' => 'Transaction not found'], 404);
293+ }
294+
295+ // Verify transaction is in pending state
296+ if ($transaction->getStatus() !== 'pending') {
297+ return new JsonResponse(['error' => 'Transaction already processed'], 400);
298+ }
299+
300+ // Create payment extension with SERVER-VALIDATED data
287301 $amount = PaymentCurrencyAmount::create(
288- $data['payment']['total']['currency'] ,
289- $data['payment']['total']['value']
302+ $transaction->getCurrency() ,
303+ $transaction->getAmount()
290304 );
291305
292306 $instrument = PaymentCredentialInstrument::create(
293- $data['payment']['instrument']['displayName'] ?? 'Card' ,
294- $data['payment']['instrument']['icon'] ?? 'https://example.com/default-icon.png'
307+ $transaction->getPaymentMethod()->getDisplayName() ,
308+ $transaction->getPaymentMethod()->getIconUrl()
295309 );
296310
297311 $paymentExtension = PaymentExtension::register(
298312 rpId: 'example.com',
299- topOrigin: 'https://merchant.example.com' ,
300- payeeName: $data['payment']['payeeName'] ,
301- payeeOrigin: $data['payment']['payeeOrigin'] ,
313+ topOrigin: $transaction->getMerchantOrigin() ,
314+ payeeName: $transaction->getPayeeName() ,
315+ payeeOrigin: $transaction->getPayeeOrigin() ,
302316 amount: $amount,
303317 instrument: $instrument
304318 );
@@ -311,8 +325,9 @@ class PaymentController
311325 extensions: new AuthenticationExtensions([$paymentExtension])
312326 );
313327
314- // Store challenge in session for verification
328+ // Store challenge and transaction ID in session for verification
315329 $_SESSION['payment_challenge'] = base64_encode($options->challenge);
330+ $_SESSION['payment_transaction_id'] = $transactionId;
316331
317332 return new JsonResponse($options);
318333 }
@@ -368,28 +383,31 @@ class PaymentController
368383 <div class =" payment-container" >
369384 <h1 >Checkout</h1 >
370385
386+ <?php
387+ // Fetch transaction from secure database
388+ $transaction = $transactionRepository->find($transactionId);
389+ ?>
390+
371391 <form data-controller =" webauthn--payment"
372392 data-action =" submit->webauthn--payment#confirmPayment"
373393 data-webauthn--payment-options-url-value =" /api/payment/options"
374394 data-webauthn--payment-result-url-value =" /api/payment/verify"
375- data-webauthn--payment-payee-name-value =" Acme Store"
376- data-webauthn--payment-payee-origin-value =" https://acme-store.example.com"
377- data-webauthn--payment-amount-value =" 149.99"
378- data-webauthn--payment-currency-value =" USD"
379- data-webauthn--payment-instrument-display-name-value =" Visa •••• 4242"
380- data-webauthn--payment-instrument-icon-value =" /images/visa-logo.png"
395+ data-webauthn--payment-transaction-id-value =" <?= htmlspecialchars($transaction->getId()) ?>"
381396 data-webauthn--payment-success-redirect-uri-value =" /payment/success" >
382397
383398 <div class =" order-summary" >
384399 <h2 >Order Summary</h2 >
385- <p >Total: <strong >$149.99 USD</strong ></p >
386- <p >Merchant: <strong >Acme Store</strong ></p >
400+ <!-- Display from server-side data, NOT from HTML attributes -->
401+ <p >Total: <strong ><?= htmlspecialchars($transaction->getFormattedAmount()) ?></strong ></p >
402+ <p >Merchant: <strong ><?= htmlspecialchars($transaction->getPayeeName()) ?></strong ></p >
387403 </div >
388404
389405 <div class =" payment-method" >
390406 <h3 >Payment Method</h3 >
391- <img src =" /images/visa-logo.png" alt =" Visa" width =" 40" >
392- <span >Visa •••• 4242</span >
407+ <img src =" <?= htmlspecialchars($transaction->getPaymentMethod()->getIconUrl()) ?>"
408+ alt =" <?= htmlspecialchars($transaction->getPaymentMethod()->getBrand()) ?>"
409+ width =" 40" >
410+ <span ><?= htmlspecialchars($transaction->getPaymentMethod()->getDisplayName()) ?></span >
393411 </div >
394412
395413 <button type =" submit" class =" btn-primary" >
@@ -450,12 +468,78 @@ if (browserSupportsWebAuthn()) {
450468
451469## Security Considerations
452470
453- 1 . ** Always validate payment data** on the server side using ` PaymentExtensionOutputChecker `
454- 2 . ** Use HTTPS** - SPC requires a secure context
455- 3 . ** Verify the challenge** matches what was sent to the client
456- 4 . ** Check the RP ID** matches your domain
457- 5 . ** Validate the payee origin** matches the expected merchant
458- 6 . ** Store credentials securely** using proper key management
471+ ### Critical Security Measures
472+
473+ 1 . ** NEVER trust client-side payment data**
474+ - Payment amounts, payee names, and merchant details must NEVER come from HTML attributes or JavaScript
475+ - Always fetch these from your secure server-side database using a transaction ID
476+ - The Stimulus controller is designed to only send a ` transactionId ` - do not modify it to accept payment details
477+
478+ 2 . ** Server-side validation is mandatory**
479+ - Always validate payment data using ` PaymentExtensionOutputChecker `
480+ - Verify the transaction belongs to the authenticated user
481+ - Check the transaction status (must be "pending")
482+ - Validate the amount hasn't been modified
483+
484+ 3 . ** Transaction ID security**
485+ - Generate cryptographically secure transaction IDs (e.g., ` bin2hex(random_bytes(16)) ` )
486+ - Store transaction state in your database with user association
487+ - Implement transaction expiry (e.g., 15 minutes)
488+ - Mark transactions as "completed" or "cancelled" after processing
489+
490+ 4 . ** Standard WebAuthn security**
491+ - Use HTTPS - SPC requires a secure context
492+ - Verify the challenge matches what was sent to the client
493+ - Check the RP ID matches your domain
494+ - Validate the payee origin matches the expected merchant
495+ - Store credentials securely using proper key management
496+
497+ ### Example: Secure Transaction Flow
498+
499+ ``` php
500+ // 1. Create transaction in database (server-side only)
501+ $transaction = new Transaction();
502+ $transaction->setId(bin2hex(random_bytes(16))); // Secure ID
503+ $transaction->setUserId($currentUser->getId());
504+ $transaction->setAmount('99.99');
505+ $transaction->setCurrency('USD');
506+ $transaction->setPayeeName('Merchant Store');
507+ $transaction->setStatus('pending');
508+ $transaction->setExpiresAt(new DateTime('+15 minutes'));
509+ $entityManager->persist($transaction);
510+ $entityManager->flush();
511+
512+ // 2. Render page with transaction ID only
513+ echo '<form data-webauthn--payment-transaction-id-value =" ' .
514+ htmlspecialchars($transaction->getId()) . '" >';
515+
516+ // 3. In options endpoint: Fetch from database
517+ $transaction = $repository->findOneBy([
518+ 'id' => $transactionId,
519+ 'userId' => $currentUser->getId(),
520+ 'status' => 'pending'
521+ ]);
522+
523+ if (!$transaction || $transaction->isExpired()) {
524+ return new JsonResponse(['error' => 'Invalid transaction'], 400);
525+ }
526+
527+ // Use $transaction data for payment extension (NOT client data!)
528+ ```
529+
530+ ### Why This Matters
531+
532+ ** Attack scenario without this protection:**
533+ 1 . User initiates payment for $10.00
534+ 2 . Attacker modifies HTML: ` data-amount-value="0.01" `
535+ 3 . Without server validation, user pays only $0.01
536+
537+ ** Protection with transaction ID:**
538+ 1 . User initiates payment for $10.00
539+ 2 . Server creates transaction with ID ` txn_abc123 ` storing amount $10.00
540+ 3 . Attacker can modify HTML attributes, but server ignores them
541+ 4 . Server always uses database amount ($10.00) from transaction ID
542+ 5 . Payment is processed for the correct amount ✅
459543
460544## Troubleshooting
461545
0 commit comments