# How Can I Secure My FlutterFlow App's Payment Data

- Tool: FlutterFlow
- Difficulty: Beginner
- Time required: 35-50 min
- Compatibility: FlutterFlow Free+
- Last updated: March 2026

## TL;DR

Secure payment data in FlutterFlow by never storing card numbers — use Stripe's tokenization so the FlutterFlow app only ever touches a `pm_xxx` token. Put the Stripe secret key in a Cloud Function Secret, never in FlutterFlow's API Manager. Verify webhook signatures server-side. Store only `last4`, `brand`, and `expiry` in Firestore. This achieves PCI SAQ-A compliance — the simplest PCI tier.

## PCI Compliance Is Easier Than You Think With Stripe

Payment Card Industry (PCI) compliance sounds intimidating, but Stripe's architecture makes the hardest part automatic. When a user enters their card number into a Stripe-hosted element (Stripe Checkout or Stripe Elements), the number goes directly from the user's browser to Stripe's servers — it never touches your app, your Firestore, or your Cloud Functions. What you receive is a payment method token (`pm_xxx`) or a charge ID — opaque strings that are useless without Stripe API access. This is the foundation of PCI SAQ-A compliance: the card data is handled entirely by a PCI-certified third party (Stripe). Your only responsibility is to protect your Stripe API keys and webhook secrets. This tutorial shows how to do that correctly in FlutterFlow.

## Before you start

- A Stripe account with Checkout or Elements enabled
- FlutterFlow project with Firebase Authentication and Cloud Functions (Blaze plan)
- Basic understanding of how Stripe Checkout and webhook events work
- Firebase console access for setting Cloud Function Secrets

## Step-by-step guide

### 1. Use Stripe Checkout instead of building a custom card form

The safest way to collect payment data is to redirect users to Stripe Checkout — a Stripe-hosted payment page where Stripe handles all card data collection. No card number ever enters your FlutterFlow app. In FlutterFlow, create a Cloud Function named `createCheckoutSession` that uses the Stripe Node.js SDK to create a checkout session with success_url and cancel_url pointing back to your app. Call this Cloud Function from FlutterFlow and open the returned `sessionUrl` in an in-app browser (use the Launch URL action with the `launchUrl` Custom Action for Flutter's url_launcher package). When the user completes payment, Stripe redirects them to your success URL. Never attempt to build a raw card number input form in FlutterFlow — even if you do not log the number, any form that collects raw card data requires the full PCI SAQ-D audit.

```
// Cloud Function: createCheckoutSession
const { onCall, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { initializeApp } = require('firebase-admin/app');
const { getFirestore } = require('firebase-admin/firestore');
const Stripe = require('stripe');

initializeApp();
const stripeSecret = defineSecret('STRIPE_SECRET_KEY');

exports.createCheckoutSession = onCall(
  { secrets: [stripeSecret] },
  async (request) => {
    const uid = request.auth?.uid;
    if (!uid) throw new HttpsError('unauthenticated', 'Sign in required');

    const { priceId, successUrl, cancelUrl } = request.data;
    const stripe = new Stripe(stripeSecret.value());

    const db = getFirestore();
    const userDoc = await db.collection('users').doc(uid).get();
    let customerId = userDoc.data()?.stripeCustomerId;

    // Create or reuse Stripe customer
    if (!customerId) {
      const customer = await stripe.customers.create({
        metadata: { firebaseUid: uid },
      });
      customerId = customer.id;
      await db.collection('users').doc(uid).update({ stripeCustomerId: customerId });
    }

    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      mode: 'payment',
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: successUrl,
      cancel_url: cancelUrl,
    });

    return { sessionUrl: session.url, sessionId: session.id };
  }
);
```

**Expected result:** Clicking Pay in your FlutterFlow app opens the Stripe Checkout page. No card data passes through FlutterFlow or Firestore.

### 2. Store the Stripe secret key in Firebase Function Secrets

Your Stripe secret key (sk_live_xxx or sk_test_xxx) must never appear in FlutterFlow's API Manager, as a Firestore document value, in a Flutter environment variable, or hardcoded in a Custom Action. Any of these locations can expose the key. The correct location is a Firebase Function Secret. Run `firebase functions:secrets:set STRIPE_SECRET_KEY` and paste your key when prompted. Access it in the Cloud Function using `defineSecret('STRIPE_SECRET_KEY')` from `firebase-functions/params`. The secret is encrypted at rest and only injected into the Cloud Function's runtime environment — it never appears in logs, function code, or Firebase console UI.

**Expected result:** Running `firebase functions:secrets:list` shows STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. Neither key appears anywhere in your FlutterFlow project.

### 3. Handle Stripe webhooks with signature verification

Stripe sends webhook events to your Cloud Function to notify you of completed payments, subscription renewals, and failed charges. Without signature verification, anyone can send a fake webhook to your endpoint claiming a payment succeeded. Create a Cloud Function named `stripeWebhook` as an HTTP function (not callable). Read the raw request body as a Buffer — this is critical because signature verification fails if the body is parsed as JSON first. Call `stripe.webhooks.constructEvent(rawBody, sig, webhookSecret.value())`. If verification fails, return HTTP 400. If it succeeds, handle the event type (e.g., `checkout.session.completed`) by updating Firestore.

```
const { onRequest } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { getFirestore } = require('firebase-admin/firestore');
const Stripe = require('stripe');

const stripeSecret = defineSecret('STRIPE_SECRET_KEY');
const webhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');

exports.stripeWebhook = onRequest(
  { secrets: [stripeSecret, webhookSecret], rawBody: true },
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    const stripe = new Stripe(stripeSecret.value());
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody,
        sig,
        webhookSecret.value()
      );
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    const db = getFirestore();

    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const customerId = session.customer;
      // Look up user by Stripe customer ID
      const usersRef = db.collection('users');
      const query = await usersRef
        .where('stripeCustomerId', '==', customerId)
        .limit(1).get();
      if (!query.empty) {
        await query.docs[0].ref.update({
          hasPaid: true,
          lastPaymentAt: new Date(),
          lastPaymentAmount: session.amount_total,
        });
      }
    }

    res.json({ received: true });
  }
);
```

**Expected result:** The Stripe Dashboard Webhook Logs show successful deliveries with 200 responses. Firestore updates correctly when a test payment is completed.

### 4. Store only safe payment metadata in Firestore

After a successful payment, you need to store some payment information in Firestore for display in your app — but you must be selective. Safe to store: `last4` (last 4 digits of the card), `brand` (Visa, Mastercard), `expMonth`, `expYear`, `chargeId` (ch_xxx), `paymentIntentId` (pi_xxx), `amount`, `currency`, `paidAt`. Never store: full card number, CVV/CVC, magnetic stripe data, PIN. The Stripe webhook handler can safely extract `last4` and `brand` from the `payment_method_details` object on a charge event. Write these fields to the user's Firestore document or a `payments` subcollection.

**Expected result:** The user's Firestore document shows last4, brand, and paidAt. No sensitive card data is stored anywhere in Firestore.

### 5. Audit your Cloud Function logs for accidental card data exposure

The most common source of card data leaks is logging full Stripe API response objects, which can contain payment method details. Review all your Cloud Functions for `console.log(response)` or `console.log(data)` statements that log entire Stripe objects. Replace these with targeted logs: `console.log('Session created:', session.id)` instead of `console.log(session)`. In the Google Cloud console, go to Logging > Log Explorer and search for your Cloud Function logs. Filter for any log entries containing `card_number`, `cvc`, or `number` to catch accidental leaks. Set up a Cloud Logging alert to notify you if these patterns appear in future logs.

**Expected result:** Log review shows no payment method details, card numbers, or CVV values in Cloud Function logs.

## Complete code example

File: `functions/payments.js`

```javascript
const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https');
const { defineSecret } = require('firebase-functions/params');
const { initializeApp } = require('firebase-admin/app');
const { getFirestore } = require('firebase-admin/firestore');
const Stripe = require('stripe');

initializeApp();

const stripeSecretKey = defineSecret('STRIPE_SECRET_KEY');
const stripeWebhookSecret = defineSecret('STRIPE_WEBHOOK_SECRET');

// Create Stripe Checkout session
exports.createCheckoutSession = onCall(
  { secrets: [stripeSecretKey] },
  async (request) => {
    const uid = request.auth?.uid;
    if (!uid) throw new HttpsError('unauthenticated', 'Sign in required');
    const { priceId, successUrl, cancelUrl } = request.data;
    const stripe = new Stripe(stripeSecretKey.value());
    const db = getFirestore();

    const userDoc = await db.collection('users').doc(uid).get();
    let customerId = userDoc.data()?.stripeCustomerId;
    if (!customerId) {
      const customer = await stripe.customers.create({ metadata: { firebaseUid: uid } });
      customerId = customer.id;
      await db.collection('users').doc(uid).update({ stripeCustomerId: customerId });
    }

    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      mode: 'payment',
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: successUrl,
      cancel_url: cancelUrl,
      payment_intent_data: { metadata: { firebaseUid: uid } },
    });

    // Log only safe identifiers, never full session object
    console.log('Checkout session created:', session.id, 'for user:', uid);
    return { sessionUrl: session.url, sessionId: session.id };
  }
);

// Handle Stripe webhooks with signature verification
exports.stripeWebhook = onRequest(
  { secrets: [stripeSecretKey, stripeWebhookSecret], rawBody: true },
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    if (!sig) return res.status(400).send('Missing stripe-signature header');

    const stripe = new Stripe(stripeSecretKey.value());
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.rawBody, sig, stripeWebhookSecret.value()
      );
    } catch (err) {
      console.error('Webhook verification failed:', err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    const db = getFirestore();

    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const customerId = session.customer;
      const amountTotal = session.amount_total;
      const currency = session.currency;

      // Retrieve payment method details (only safe metadata)
      let last4 = null;
      let brand = null;
      if (session.payment_intent) {
        const pi = await stripe.paymentIntents.retrieve(session.payment_intent, {
          expand: ['payment_method'],
        });
        last4 = pi.payment_method?.card?.last4 || null;
        brand = pi.payment_method?.card?.brand || null;
      }

      const usersRef = db.collection('users');
      const query = await usersRef
        .where('stripeCustomerId', '==', customerId).limit(1).get();

      if (!query.empty) {
        const userRef = query.docs[0].ref;
        await userRef.update({ hasPaid: true, lastPaymentAt: new Date() });
        await userRef.collection('payments').add({
          sessionId: session.id,
          amount: amountTotal,
          currency,
          last4,
          brand,
          paidAt: new Date(),
        });
      }

      console.log('Payment completed for customer:', customerId, 'amount:', amountTotal);
    }

    res.json({ received: true });
  }
);
```

## Common mistakes

- **Logging full Stripe API responses which may contain card details** — Stripe API responses for payment intents and charges can include `payment_method_details.card` objects with sensitive fields. Logging the entire response object with `console.log(response)` writes these to Cloud Logging, where they are retained and can be accessed by anyone with Logging Viewer access. Fix: Only log specific safe fields: `console.log('Payment created:', paymentIntent.id)`. Never log the full Stripe response object. Run a search of your Cloud Logging logs for 'card_number' and 'cvc' to audit for existing leaks.
- **Storing the Stripe secret key in FlutterFlow's API Manager header fields** — FlutterFlow API Manager stores API keys in the app's configuration, which can be extracted from the compiled app binary or found in network traffic. The Stripe secret key grants full API access — charge any customer, issue refunds, and access all financial data. Fix: Store the Stripe secret key exclusively as a Firebase Function Secret. The Cloud Function is the only layer that should ever call the Stripe API. FlutterFlow only calls your Cloud Function, not Stripe directly.
- **Processing Stripe webhooks without verifying the signature** — Anyone who discovers your webhook URL can POST a fake `checkout.session.completed` event claiming a payment succeeded, granting themselves access to paid features without paying. Fix: Always call `stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)` before processing any webhook event. This cryptographically verifies the event came from Stripe. Use the raw request body, not a JSON-parsed body, for signature verification.

## Best practices

- Use Stripe Checkout or Stripe Elements — never build a raw card number input form, which requires full PCI SAQ-D compliance.
- Store all Stripe API keys and webhook secrets as Firebase Function Secrets, never in FlutterFlow API Manager, Firestore, or app code.
- Always verify webhook signatures with `stripe.webhooks.constructEvent` before trusting any webhook payload.
- Store only last4, brand, expMonth, and expYear in Firestore — never store full card numbers, CVV, or raw magnetic stripe data.
- Use targeted logging in Cloud Functions — log only Stripe IDs (session.id, customer.id), never full API response objects.
- Test your entire payment flow with Stripe test mode (4242 4242 4242 4242) before going live — the webhook signing secret is different in test and live mode.
- Enable Stripe Radar fraud protection rules in the Stripe Dashboard — it blocks known fraudulent cards automatically at no extra cost.

## Frequently asked questions

### Does using Stripe Checkout make my app PCI compliant?

Yes — using Stripe Checkout qualifies you for PCI SAQ-A, the simplest PCI Self-Assessment Questionnaire. This requires no security auditor, just an annual self-assessment form. The card data never touches your systems — Stripe handles all PCI-sensitive data on their certified infrastructure.

### Can I store a user's card for future payments?

Yes, but through Stripe's saved payment method system, not by storing card data in Firestore. In the Checkout session, set `setup_future_usage: 'off_session'`. Stripe saves the payment method and returns a payment method ID (pm_xxx). Store only the payment method ID and metadata (last4, brand) in Firestore. For future charges, pass the payment method ID to `stripe.paymentIntents.create`.

### What is the difference between the Stripe publishable key and the secret key?

The publishable key (pk_test_xxx or pk_live_xxx) is safe to use in client-side code — it can only create tokens and initialize Stripe Elements. The secret key (sk_xxx) can perform any API operation including issuing refunds, listing all customers, and accessing financial data. The secret key must only ever appear in your Cloud Function Secrets.

### Why does Stripe say my webhook signature verification failed?

The most common cause is the request body being parsed as JSON before being passed to `constructEvent`. Signature verification requires the raw bytes of the request body. In Firebase Functions v2, set `rawBody: true` in the function configuration and use `req.rawBody` instead of `req.body`. Also verify you are using the correct webhook secret for the environment — test and live webhooks have separate signing secrets.

### How do I handle payment failures in the webhook?

Listen for the `payment_intent.payment_failed` event in your webhook handler. When it fires, update the user's Firestore document to reflect the failed status and optionally trigger a notification. Stripe automatically retries failed subscription payments according to your configured retry rules in the Stripe Dashboard > Billing > Retry rules.

### Should I use Stripe Checkout or Stripe Elements in my FlutterFlow app?

Stripe Checkout is strongly recommended for FlutterFlow apps because it opens a Stripe-hosted page — no card data touches your app at all. Stripe Elements (embedded card form) requires more complex integration, needs a custom Flutter package, and still qualifies for PCI SAQ-A as long as you use Stripe.js. For most FlutterFlow founders, Checkout's simplicity and security outweigh the branding limitations.

---

Source: https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-secure-my-flutterflow-app-s-payment-data
© RapidDev — https://www.rapidevelopers.com/flutterflow-tutorials/how-can-i-secure-my-flutterflow-app-s-payment-data
