← Back to list

Securing Flutter API Calls: A Robust Guide to Hardware-Backed Digital Signatures

In the world of mobile finance and high-stakes data exchange, standard HTTPS/TLS isn’t always enough. While TLS secures the “pipe,” it…

James R G in MeetCyber · 2026-05-09 11:45 · 0 claps · 6.9 min read
#afasa #flutter #ecdsa #payload-binding #data-security
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏔️ · Outdoor & Adventure

Securing Flutter API Calls: A Robust Guide to Hardware-Backed Digital Signatures

In the world of mobile finance and high-stakes data exchange, standard HTTPS/TLS isn’t always enough. While TLS secures the “pipe,” it doesn’t guarantee that the message inside wasn’t tampered with by a malicious actor on the device or that a valid request isn’t being “replayed” to drain an account.

To achieve bank-grade security, we must move toward Request Signing. In this article, I will show you how to implement digital signatures in Flutter, prioritizing hardware-backed security while providing a reliable software fallback.

The Core Concept: Digital Signatures

Instead of just sending a JSON body, the app generates a Signature based on the transaction data.

  1. Private Key: Stored securely on the device; signs the data.
  2. Public Key: Passed by mobile app to the backend; verifies the signature.
  3. Canonical String: A standardized string of your transaction data (Amount, Account, Nonce, Timestamp, etc.) that ensures both the app and the server are looking at the exact same data points.

Phase 1: The Gold Standard (Hardware-Backed)

Whenever possible, your business should prioritize hardware-based security. By using the device’s Trusted Execution Environment (TEE) or Secure Enclave, the private key is generated inside the hardware. It never leaves the chip, and even you, the developer, cannot “see” it.

1. Key Generation

We use the biometric_signature library from https://pub.dev/packages/biometric_signature to interface with the hardware. During the onboarding or OTP process, we check for a key pair.

Dart

Future<void> createHardwareKeys() async {
  final biometricSignature = BiometricSignature();
  String keyAlias = "MY_APP_SECURE_KEY";  
final result = await biometricSignature.createKeys(
    keyAlias: keyAlias,
    keyFormat: KeyFormat.pem,
    promptMessage: 'Authenticate to secure your account',
    config: CreateKeysConfig(
      signatureType: SignatureType.ecdsa,
      enforceBiometric: false, // Set true for extra security
      useDeviceCredentials: true,
      failIfExists: true, 
    ),
  );if (result.code == BiometricError.success) {
    // Send result.publicKey to your backend to bind it to the user profile
    await uploadPublicKey(result.publicKey);
  }
}

2. Signing the Transaction

When a user sends money, we create a canonical string, sign it, and attach a nonce (a random number used once) and a timestamp.

Dart

void sendTransaction() async {
  String nonce = DateTime.now().microsecondsSinceEpoch.toString();
  String timestamp = DateTime.now().millisecondsSinceEpoch.toString();

  // Create a predictable string format
  String payload = "10000|639661234567|$sessionId|$nonce|$timestamp";
final biometricSignature = BiometricSignature();
  final result = await biometricSignature.createSignature(
    payload: payload,
    keyAlias: "MY_APP_SECURE_KEY",
    promptMessage: 'Please authenticate to confirm transfer',
  );// Send payload + result (signature) + nonce + timestamp to API
}

Phase 2: The Alternative (Software-Based)

If your business requirements involve devices without biometric hardware or specific legacy support, you can use Secure Storage https://pub.dev/packages/flutter_secure_storage combined with the ecdsa package https://pub.dev/packages/ecdsa. While less secure than a TEE, it is still significantly better than plain API calls.

  • Generate an Elliptic Curve (EC) key pair. You can use https://pub.dev/packages/elliptic
  • Store the private key using flutter_secure_storage.
  • Sign the payload using the ecdsa library.

Note: In this flow, you are responsible for the cryptographic math. Ensure you use sha256 to hash your payload before signing.

Phase 3: Backend Verification (Java/Spring)

The backend is the ultimate gatekeeper. It retrieves the stored Public Key for the specific device_id and verifies the incoming signature.

The Verification Logic

The backend must perform three checks:

  • Integrity: Does the signature match the payload?
  • Freshness: Is the timestamp within a 5-minute window?
  • Uniqueness: Has this nonce been used before? (Preventing Replay Attacks).

Java

import java.security.*;
import java.security.spec.*;
import java.util.Base64;
import java.nio.charset.StandardCharsets;

public static boolean verifySignatureWithTimestamp(
        String rawPemKey, 
        String canonicalPayload, 
        String signatureBase64,
        long timestamp,                    // Timestamp from request payload
        int maxAgeMinutes) {               // e.g. 5 minutes, based on redis TTL

    try {
        long now = System.currentTimeMillis();
        long ageMs = now - timestamp;

        System.out.println("Current Time      : " + now);
        System.out.println("Payload Timestamp : " + timestamp);
        System.out.println("Age               : " + (ageMs / 1000) + " seconds");

        // === 1. Timestamp Freshness Check ===
        if (timestamp > now + 60_000) { // 1 minute future tolerance
            System.out.println("Timestamp is in the future!");
            return false;
        }

        if (ageMs > (maxAgeMinutes * 60L * 1000)) {
            System.out.println("Request expired! (Older than " + maxAgeMinutes + " minutes)");
            return false;
        }

        System.out.println("✅ Timestamp is fresh");

        // === 2. Signature Verification ===
        // Clean PEM key
        String cleanKey = rawPemKey
                .replace("-----BEGIN PUBLIC KEY-----", "")
                .replace("-----END PUBLIC KEY-----", "")
                .replaceAll("\\s", "");

        byte[] publicBytes = Base64.getDecoder().decode(cleanKey);
        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);

        // Reconstruct Public Key
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicBytes);
        KeyFactory keyFactory = KeyFactory.getInstance("EC");
        PublicKey publicKey = keyFactory.generatePublic(keySpec);

        // Verify Signature
        Signature ecdsaVerify = Signature.getInstance("SHA256withECDSA");
        ecdsaVerify.initVerify(publicKey);
        ecdsaVerify.update(canonicalPayload.getBytes(StandardCharsets.UTF_8));

        boolean isValid = ecdsaVerify.verify(signatureBytes);

        System.out.println("✅ Signature Verification: " + (isValid ? "VALID" : "INVALID"));
        return isValid;

    } catch (Exception e) {
        System.err.println("❌ Verification Error: " + e.getMessage());
        e.printStackTrace();
        return false;
    }
}

Full code demo

Helper functions

bool sampleVerifySignature(
  ec.PublicKey publicKey,
  String payload,
  Uint8List signatureBytes,
) {
  try {
    final payloadBytes = utf8.encode(payload);
    final hash = sha256.convert(payloadBytes).bytes;

    // Most signatures from Java backend will be in DER format
    final signature = ecdsa_sig.Signature.fromDER(signatureBytes);

    return ecdsa_sig.verify(publicKey, hash, signature);
  } catch (e) {
    print('❌ Verification Error: $e');
    return false;
  }
}

/// Converts elliptic PublicKey to X.509 SubjectPublicKeyInfo (Base64)
String publicKeyToX509Base64(ec.PublicKey publicKey) {
  final rawBytes = publicKeyToBytes(publicKey);           // 65 bytes
  final x509Bytes = _createX509PublicKey(rawBytes);
  return base64.encode(x509Bytes);
}

/// Returns full PEM format (recommended for Java)
String publicKeyToPEM(ec.PublicKey publicKey) {
  final base64Key = publicKeyToX509Base64(publicKey);
  return '''-----BEGIN PUBLIC KEY-----
$base64Key
-----END PUBLIC KEY-----''';
}

// Convert PublicKey to raw uncompressed bytes (0x04 + X + Y)
Uint8List publicKeyToBytes(ec.PublicKey pub) {
  final xBytes = _bigIntToBytes(pub.X, 32);
  final yBytes = _bigIntToBytes(pub.Y, 32);
  return Uint8List.fromList([0x04, ...xBytes, ...yBytes]);
}

Uint8List _bigIntToBytes(BigInt number, int byteLength) {
  String hex = number.toRadixString(16).padLeft(byteLength * 2, '0');
  final bytes = <int>[];
  for (int i = 0; i < hex.length; i += 2) {
    bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
  }
  return Uint8List.fromList(bytes);
}

// Fixed X.509 encoding
Uint8List _createX509PublicKey(Uint8List rawPubKey) {
  // Algorithm Identifier (ecPublicKey + secp256r1)
  final algId = Uint8List.fromList([
    0x30, 0x13,
    0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, // ecPublicKey
    0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07 // secp256r1
  ]);

  // BIT STRING: tag + length + unused bits + data
  final bitString = Uint8List(3 + rawPubKey.length);   // Fixed size
  bitString[0] = 0x03;                    // BIT STRING tag
  bitString[1] = (rawPubKey.length + 1);  // Length
  bitString[2] = 0x00;                    // No unused bits
  bitString.setAll(3, rawPubKey);         // Copy public key bytes

  // Final SEQUENCE
  final totalLength = algId.length + bitString.length;
  final result = Uint8List(2 + totalLength);
  result[0] = 0x30;                       // SEQUENCE
  result[1] = totalLength;
  result.setAll(2, algId);
  result.setAll(2 + algId.length, bitString);

  return result;
}

bool verifySignatureWithTimestamp({
  required ec.PublicKey publicKey,
  required String canonicalPayload, // full signed string
  required Uint8List signatureBytes,
  required int timestamp, // timestamp from payload
  int maxAgeMinutes = 5, // to be set in accordance to redis ttl
}) {
  try {
    // === 1. Timestamp Freshness Check ===
    final now = DateTime.now().millisecondsSinceEpoch;
    final ageMs = now - timestamp;
    final maxAgeMs = maxAgeMinutes * 60 * 1000;

    print("Current Time : $now");
    print("Payload Timestamp: $timestamp");
    print("Age (seconds) : ${ageMs ~/ 1000}s");

    if (ageMs < 0) {
      print("Timestamp is invalid!");
      return false;
    }

    if (ageMs > maxAgeMs) {
      print("Expired! (Older than $maxAgeMinutes minutes)");
      return false;
    }

    print("Timestamp okay pa");

    // signature verif here
    final payloadBytes = utf8.encode(canonicalPayload);
    final hash = sha256.convert(payloadBytes).bytes;

    final signature = ecdsa_sig.Signature.fromDER(signatureBytes);

    final isSignatureValid = ecdsa_sig.verify(publicKey, hash, signature);

    print(
        "Signature Verification: ${isSignatureValid ? "VALID" : "INVALID"}");

    return isSignatureValid;
  } catch (e) {
    print('Verification Error: $e');
    return false;
  }
}

String buildCanonicalPayload({
  required String amount,
  required String account,
  required String nonce,
  required int timestamp, // millisecondsSinceEpoch
  String? fromAccount,
}) =>
    [
      amount,
      account,
      nonce,
      timestamp.toString(),
    ].join('|');

Test run (dart)

void main() {

  final timestamp = DateTime.now().millisecondsSinceEpoch;
  final int oldTimestamp = 1478244641454; //used to test old timestamp
  String nonce = "12345"; //this should be random, for testing we use basic value

  //Part of your payload to be signed. Sign only the important details + nonce + timestamp
  String testPayload = buildCanonicalPayload(
      amount: "1000",
      account: "09664466316",
      nonce: nonce,
      timestamp: timestamp);

  //for testing purposes we use a predefined private key.
  //in your actual app, this should be should generated 
  ec.PrivateKey mobilePrivate = convertStringToKey(
    '2d734ce1fce532ce5454f96691449fb1907e07b763c46c65c069573644af10aa',
    'private',
  );

  String signatureBase64 = signAndEncode(mobilePrivate, testPayload);
  print("Signature (Base64): $signatureBase64");
  print("pub key: ${mobilePrivate.publicKey}");
  print("payload: $testPayload");

  //First test
  bool isValid = verifySignatureWithTimestamp(
      publicKey: mobilePrivate.publicKey,
      canonicalPayload: testPayload,
      signatureBytes: base64.decode(signatureBase64),
      timestamp: timestamp);

  print("Test 1: Happy path. Signature status: $isValid");

  //Second test
  bool isValidUsingExpiredTimetamp = verifySignatureWithTimestamp(
      publicKey: mobilePrivate.publicKey,
      canonicalPayload: testPayload,
      signatureBytes: base64.decode(signatureBase64),
      timestamp: oldTimestamp);

  print("Test 2: Using expired timestamp. Signature status: $isValid");

  //for java use
  String x509Base64 = publicKeyToX509Base64(mobilePrivate.publicKey);
  print("X509 Base64: $x509Base64");
}

OUTPUT:

Signature (Base64): MEQCIF5nZ4tdtnJsUHs6+UE8h2Hes4/0jTuIWfJvlGz+XJ7hAiAjZ0rSQ3a1kDTD/KEv5Of3Q97T7lNpRLJV3jAktOAkLg==

pub key: 04d2f48e907257211b83ad856bf8fe74ab380e2d618fd207bce8d3a7beea5f085080f4932ee227c6c96a86c7ccfe23494f7abebf9a6d874aa3bcba033641c68105 payload: 1000|09664466316|12345|1778244792713

Current Time: 1778244792743 Payload Timestamp: 1778244792713 Age (seconds): 0s Timestamp is fresh Signature Verification: VALID

Test 1: Happy path. Signature status: true

Current Time : 1778244792751 Payload Timestamp: 1478244641454 Age (seconds) : 300000151s Request expired! (Older than 5 minutes)

Test 2: Using expired timestamp. Signature status: true

X509 Base64: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0vSOkHJXIRuDrYVr+P50qzgOLWGP0ge86NOnvupfCFCA9JMu4ifGyWqGx8z+I0lPer6/mm2HSqO8ugM2QcaBBQ==

Test run (Java)

 public static void main(String[] args) {

        long now = System.currentTimeMillis();
        long old = 1478244792713L;
        String rawPemKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0vSOkHJXIRuDrYVr+P50qzgOLWGP0ge86NOnvupfCFCA9JMu4ifGyWqGx8z+I0lPer6/mm2HSqO8ugM2QcaBBQ==";
        String signatureBase64 = "MEQCIF5nZ4tdtnJsUHs6+UE8h2Hes4/0jTuIWfJvlGz+XJ7hAiAjZ0rSQ3a1kDTD/KEv5Of3Q97T7lNpRLJV3jAktOAkLg==";
        boolean x = verifySignatureWithTimestamp(rawPemKey, "1000|09664466316|12345|1778244792713", signatureBase64, now, 5);
        System.out.println("test 1: happy path. Is Valid transaction:"+x);

        boolean y = verifySignatureWithTimestamp(rawPemKey, "1000|09664466316|12345|1778244792713", signatureBase64, old, 5);
        System.out.println("test 2: expired time. Is Valid transaction: "+y);

    }

Results:

Success

Current Time      : 1778245993907
Payload Timestamp : 1778245993907
Age               : 0 seconds
✅ Timestamp is fresh
✅ Signature Verification: VALID
test 1: happy path. Is Valid transaction:true
Current Time      : 1778245993936
Payload Timestamp : 1478244792713
Age               : 300001201 seconds
Request expired! (Older than 5 minutes)
test 2: expired time. Is Valid transaction: false

Process finished with exit code 0

If payload is altered: “10000|09660066300|12345|1778244792713”

false
Process finished with exit code 0

If signatureBase64 is tampered

Verification Error: Last unit does not have enough valid bits
false

Process finished with exit code 0

if rawPemKey is tampered

Verification Error: Input byte array has incorrect ending byte at 124
false

Process finished with exit code 0

Preventing Replay Attacks

Even with a valid signature, an attacker could intercept a “Send Money” request and send it again. To stop this:

  • Nonce Tracking: Store used nonces in a fast cache (like Redis) with a TTL matching your timestamp expiry (e.g., 5 minutes). If a nonce repeats, reject it.
  • Preflight Approach: For ultra-secure environments, the backend can issue a one-time nonce via a separate API call. The transaction API then requires this specific nonce to function.

Conclusion

Securing API calls is about layers. By using Hardware-Backed Signatures, you ensure that even if a device is compromised, the “signing identity” remains safe inside the hardware enclave. This architecture makes payload tampering and replay attacks detectable and rejectable by the backend, even if requests pass through a compromised network or client environment.

Have you implemented request signing in your Flutter apps? Let’s discuss the challenges in the comments!

Tags: Flutter, ECC, ECDSA, App Security, Cryptography, Mobile Development, Java


메타데이터
post_id
c68b23ac02e5
slug
securing-flutter-api-calls-a-robust-guide-to-hardware-backed-digital-signatures-c68b23ac02e5
url
https://meetcyber.net/securing-flutter-api-calls-a-robust-guide-to-hardware-backed-digital-signatures-c68b23ac02e5
canonical_url
https://meetcyber.net/securing-flutter-api-calls-a-robust-guide-to-hardware-backed-digital-signatures-c68b23ac02e5
author_url
https://medium.com/@jamesreubengruta
status
ok
fetched_at
2026-06-23 17:05:31