Stripe's documentation separates keys into two categories for a reason. The publishable key is designed to appear in client-side code. The secret key is not. Using the secret key in a Flutter application and distributing it to users is a security failure regardless of whether you obfuscate it.
A compiled Flutter application can be extracted from an Android device, decompiled with tools like apktool, and searched for string patterns. Secrets embedded in compiled Dart code are not reliably protected by obfuscation. The mobile binary is considered hostile territory for sensitive credentials.
PixelPlot's Stripe Payment Gateway project applies this architecture: a Python Flask server holds the secret key, the Flutter app holds only the publishable key, and the PaymentIntent flow ensures the two never need to cross that boundary.
Stripe's PaymentIntents API is designed around this separation.
ClientSecret.ClientSecret to Flutter.flutter_stripe SDK uses the ClientSecret to present the native payment sheet and confirm the charge directly with Stripe.The secret key is never transmitted to the Flutter app. The ClientSecret it receives is a short-lived token scoped to one specific payment amount. Even if intercepted, it cannot be used to access account data, issue refunds, or create other charges.
The Flask endpoint creates a PaymentIntent and returns the ClientSecret. The Stripe secret key is loaded from an environment variable, not hardcoded.
import os
import stripe
from flask import Flask, request, jsonify
app = Flask(__name__)
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')
@app.route('/create-payment-intent', methods=['POST'])
def create_payment_intent():
data = request.get_json()
if not data:
return jsonify({'error': 'Missing request body'}), 400
amount_raw = data.get('amount')
currency = data.get('currency', 'usd')
if amount_raw is None:
return jsonify({'error': 'Missing amount'}), 400
try:
# Stripe requires the smallest currency unit as an integer
# $10.50 USD becomes 1050 cents — float arithmetic requires round()
amount_cents = round(float(amount_raw) * 100)
except (ValueError, TypeError):
return jsonify({'error': 'Invalid amount format'}), 400
try:
intent = stripe.PaymentIntent.create(
amount=amount_cents,
currency=currency,
automatic_payment_methods={'enabled': True},
)
return jsonify({'clientSecret': intent.client_secret})
except stripe.error.StripeError as e:
return jsonify({'error': e.user_message}), 402The round() on the currency conversion is not decorative. Floating-point arithmetic in Python can produce 1049.9999999 instead of 1050 when multiplying certain decimal values. Passing a float directly to Stripe causes a validation error because Stripe's API requires an integer.
For error handling, avoid returning raw StripeError objects to the client. In stripe-python v2 and earlier, e.user_message provided a safe user-facing string. In v3+, use e.user_message if available or fall back to str(e) since the attribute may be None on some error types. Check your installed SDK version.
The Flutter side requests the ClientSecret and passes it to the flutter_stripe package. The publishable key is set on the Stripe singleton, typically during app initialization.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_stripe/flutter_stripe.dart';
import 'package:http/http.dart' as http;
class PaymentService {
final String serverUrl;
PaymentService({required this.serverUrl});
Future<void> presentPaymentSheet({
required String amountString,
required String currency,
}) async {
// Step 1: Request a PaymentIntent from the server
final response = await http.post(
Uri.parse('$serverUrl/create-payment-intent'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'amount': amountString, 'currency': currency}),
);
if (response.statusCode != 200) {
final body = jsonDecode(response.body) as Map<String, dynamic>;
throw Exception(body['error'] ?? 'Payment setup failed');
}
final clientSecret =
(jsonDecode(response.body) as Map<String, dynamic>)['clientSecret'] as String;
// Step 2: Initialize the payment sheet with the ClientSecret
await Stripe.instance.initPaymentSheet(
paymentSheetData: SetupPaymentSheetParameters(
paymentIntentClientSecret: clientSecret,
merchantDisplayName: 'PixelPlot',
),
);
// Step 3: Present the native UI — Stripe handles card tokenization from here
await Stripe.instance.presentPaymentSheet();
}
}After presentPaymentSheet() returns without throwing, the payment is confirmed. The Flutter app never handles raw card numbers. Card tokenization happens inside the Stripe SDK's native UI.
The distinction between the publishable key and the secret key is not about the key being "safer." It is about what each key can do.
The publishable key (pk_live_...) can only tokenize card data within Stripe's SDK. It cannot create charges, issue refunds, retrieve customer records, or interact with your Stripe account balance. Stripe designed it to be public.
The secret key (sk_live_...) can do everything. Exposing it in a distributed binary is a full account compromise.
The Flask server can be deployed to any platform that supports Python. Cloud providers offer their own secrets management. AWS Secrets Manager, Google Secret Manager, and Heroku config vars are all appropriate for storing the Stripe secret key. Committing a .env file containing live keys to a version control repository is a common and serious mistake.
The backend must run over HTTPS. A ClientSecret transmitted over plain HTTP can be intercepted and used to confirm the payment from a different device before the legitimate user completes checkout.
Stripe's older Charges API supports client-side token generation, but it does not support 3D Secure authentication or many modern payment methods. The PaymentIntents API is the current recommended approach and requires server-side creation.
Yes. A Vercel Edge Function, AWS Lambda, or Google Cloud Function can replace Flask with no change to the Flutter side. The API contract is the same. Flask is one option, not a requirement.
The PaymentIntent remains on Stripe's side in requires_payment_method or requires_confirmation state. You can retrieve it by customer or payment intent ID and pass the same ClientSecret to the payment sheet again rather than creating a new intent.
Most currencies use 100 as the multiplier (cents, pence). Some currencies, such as Japanese Yen, are zero-decimal and require the amount as-is without multiplication. Stripe's documentation lists the zero-decimal currencies explicitly. Handle this case in the server-side conversion logic.