← Back to list

How to secure your Flutter app

Mobile security is a losing position. The binary is on the attacker’s phone. The phone might be rooted. There’s a TLS-terminating MDM…

Abdelrahman Youssef · 2026-04-27 08:15 · 1 claps · 13.7 min read
#flutter-security
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

How to secure your Flutter app

Mobile security is a losing position. The binary is on the attacker’s phone. The phone might be rooted. There’s a TLS-terminating MDM profile sitting between the app and your API. Any of those problems on its own is fixable; together they mean you can’t reach “secure,” you can only reach “expensive enough.”

This is what we did to make our app expensive enough: keeping secrets out of the binary, locking down storage, detecting compromised devices, and hardening the network layer against replay and races.

1. Keeping secrets out of the binary with envied

Endpoint paths, base URLs, refresh hints, SSL pin fingerprints. None of those should live in source. They sit in .env, which is gitignored and injected by CI for release builds. At build time, envied generates env.g.dart with the values baked in, obfuscated rather than plaintext.

@Envied(path: '.env', obfuscate: true)
abstract class Env {
  @EnviedField(varName: 'BASE_URL')
  static final String baseUrl = _Env.baseUrl;

  @EnviedField(varName: 'ENDPOINT')
  static final String refreshToken = _Env.endpoint;
  ...
}

obfuscate: true emits the values as XOR-encrypted byte arrays decoded at runtime. A determined reverse engineer will get past it. What it stops is the trivial case: running strings on an APK and getting every URL in three seconds.

If a build leaks, at least the .env doesn’t go with it.

2. Secure storage

Every token and every piece of identity state goes through a single class, StorageHelper. It uses flutter_secure_storage under the hood, which maps to:

  • iOS: Keychain Services with kSecAttrAccessibleAfterFirstUnlock.
  • Android: EncryptedSharedPreferences backed by the Android Keystore.
class StorageHelper {
  static const _storage = FlutterSecureStorage();

  static const String _accessToken = 'accessToken';
  static const String _refreshToken = 'refreshToken';
  ...
}

On Android with a StrongBox or TEE, the encryption keys never leave secure hardware. A rooted device can’t pull them out without physical tampering that breaks other things on the way. On iOS, Keychain items are scoped to the app’s bundle ID, so another app can’t read them. And because every read and write goes through this one class, there’s no second code path that accidentally writes a token to SharedPreferences and forgets about it.

3. Tamper detection on launch

On every authenticated launch, the splash screen runs three checks in parallel:

static Future<bool> isTampered() async {
  if (kDebugMode) return false;

  final results = await Future.wait([
    SafeDevice.isJailBroken,
    SafeDevice.isRealDevice.then((v) => !v),
    SideLoadHelper.isSideLoaded(),
  ]);

  final isJailBroken = results[0];
  final isEmulator = results[1];
  final isSideLoaded = results[2];

  if (isJailBroken || isEmulator || isSideLoaded) {
    final reason = [
      if (isJailBroken) 'jailbroken/rooted',
      if (isEmulator) 'emulator',
      if (isSideLoaded) 'sideLoaded',
    ].join(', ');

    await FirebaseCrashlytics.instance.recordError(
      Exception('Tamper detected: $reason'),
      null,
      reason: 'tamper_detection',
      fatal: false,
    );
    return true;
  }
  return false;
}

Debug builds short-circuit. The if (kDebugMode) return false line is there because developers run on emulators all day. If we don’t bail in debug, we break our own workflow.

The branch that returns true does two things at once. It writes a non-fatal Crashlytics event with the specific reason, and it tells the splash to route the user to AppRoutes.accessBlocked:

final isTampered = result[2] as bool;
if (isTampered && context.mounted) {
  context.go(AppRoutes.accessBlocked);
  return;
}

The Crashlytics event still earns its keep. It’s how we spot tamper patterns in the wild: regional spikes, sudden sideload upticks, new root frameworks. But the user no longer reaches authenticated surfaces, so detection and enforcement run together.

We also fail open on errors. If any of the three checks throws, the outer try/catch returns false. A misbehaving SafeDevice should never lock out legitimate users, because a detection failure that punishes legitimate users is worse than missing a detection.

What each check actually catches:

SafeDevice.isJailBroken covers root on Android (Magisk, SuperSU and friends) and jailbreaks on iOS (Cydia, Sileo). SafeDevice.isRealDevice returns false on Android emulators and the iOS Simulator, picked up from build fingerprints and known emulator signatures. SideLoadHelper.isSideLoaded flags anything installed outside the trusted stores, which the next section walks through in detail.

4. SideLoad detection: the installer allowlist

SideLoadHelper asks the OS where the app was installed from. On Android:

static const _allowedAndroidInstallers = {
  'com.android.vending',   // Google Play Store
  'com.huawei.appmarket',  // Huawei AppGallery
};

static Future<bool> _isAndroidSideLoaded() async {
  final info = await PackageInfo.fromPlatform();
  final installer = info.installerStore;
  if (installer == null || installer.isEmpty) return true;
  return !_allowedAndroidInstallers.contains(installer);
}

If the installer package isn’t on the allowlist, or is missing entirely, the install is flagged. A missing installer is the classic tell of an adb install or a direct APK download. The legitimate stores always stamp their package name on the install record.

iOS is simpler. The App Store and TestFlight populate installerStore with a com.apple.* value. Empty means no legitimate installer.

static Future<bool> _isIOSSideLoaded() async {
  final info = await PackageInfo.fromPlatform();
  final installer = info.installerStore;
  if (installer != null && installer.isNotEmpty) return false;
  return true;
}

The whole thing is wrapped in a try/catch that fails open. A sideloaded verdict that does land feeds back into the same tamper gate from section 3, so sideloaded installs get hard-blocked alongside jailbreaks and emulators.

5. Token lifecycle: short access, silent refresh

Authentication is a three-token system. The access token is short-lived (≈30 minutes) and goes on every request. The refresh token is long-lived and only mints new access tokens. The FCM token identifies the device and is bound to the user server-side.

a. Every request carries the access token

AuthorizationInterceptor attaches the bearer on the way out:

class AuthorizationInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final String token = await StorageHelper.getToken();
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }
}

It reads from StorageHelper on every request rather than a cached variable, so a refresh that lands mid-flight is picked up immediately.

b. 401 triggers a single-flight refresh

RefreshTokenInterceptor is where the actual work happens. When a request comes back 401, it queues the failed request, calls the refresh endpoint on a clean Dio instance with no interceptors so it can’t recurse, and then either replays everything in the queue with the new token or wipes storage and bounces to login.

Subsequent 401s arriving during a refresh land in the same queue, so we don’t get a thundering herd of refresh calls.

class RefreshTokenInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
  if (err.response?.statusCode != 401) return handler.next(err);

  if (_isRefreshing) {
    _pendingRequests.add((handler: handler, options: err.requestOptions));
    return;
  }

  _isRefreshing = true;
  _pendingRequests.add((handler: handler, options: err.requestOptions));

  _tryRefreshToken().then((success) {
    _isRefreshing = false;
    final pending = List.of(_pendingRequests);
    _pendingRequests.clear();

    if (success) {
      for (final req in pending) {
        _retryRequest(req.options).then(
          (response) => req.handler.resolve(response),
          onError: (e) => req.handler.next(e is DioException ? e : err),
        );
      }
    } else {
      _onRefreshFailed();
      for (final req in pending) req.handler.next(err);
    }
  });
}

void _onRefreshFailed() {
  StorageHelper.clearStorage();
  AppRouter.goRouter.goNamed(AppRoutes.login);
}

Two details I’d lift into any other project.

_isRefreshing is a mutex. Without it, a screen with five parallel 401s would fire five refresh calls. Four of them race, the server rotates the refresh token between them, and the user gets logged out for no reason they can see.

The Dio used for the refresh call is bare. No auth interceptor, no refresh interceptor. A 401 on the refresh call itself doesn’t retrigger the refresh loop.

Failure is final. Storage is wiped, the user goes to login, and there’s no partial state or stale token sitting in memory.

c. Device binding on refresh

The refresh payload isn’t only the refresh token. It includes deviceId, deviceType, fcmToken, and language:

data: {
'refreshToken': refreshToken,
'fcmToken': fcmToken,
'deviceType': deviceType,
'deviceId': deviceId,
'Language': AppLanguage.languageEnum.id,
},

The backend can reject a refresh token presented from a device that doesn’t match the one it was issued for. That makes stolen-token replay much harder. You can lift the refresh token off a backup, but you can’t easily convince the server you’re the original device.

6. Forced updates

ForceUpdateHelper queries the stores on every launch and blocks the app behind an unskippable sheet if the current version is older than the store minimum.

static Future<bool> checkForUpdate(BuildContext context) async {
  try {
    final upgrader = Upgrader();
    await upgrader.initialize();

    if (!upgrader.isUpdateAvailable()) return false;
    if (!context.mounted) return false;

    ForceUpdateSheet.show(context: context, upgrader: upgrader);
    return true;
  } catch (_) {
    return false;
  }
}

Forced updates aren’t really about feature rollout for us. They’re about patch delivery. When a vulnerability gets fixed in a release, this is what guarantees no user is still running the vulnerable binary against the live backend a week later. The store check fails open so a network blip on the store API doesn’t brick the app.

7. Crashlytics as the anomaly channel

Every catch block in FCMNotificationHelper, TamperDetectionHelper, and the boot path routes to Crashlytics:

FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterError;

// on a suspicious event:
await FirebaseCrashlytics.instance.recordError(
  Exception('Tamper detected: $reason'),
  null,
  reason: 'tamper_detection',
  fatal: false,
);

Crashlytics ends up doing more than crash tracking. Every suspicious or unexpected event gets a typed exception with a reason tag, so production dashboards can answer “are we seeing more rooted devices after the last release?” without anyone shipping new code.

8. Certificate pinning

Bearer tokens over TLS aren’t enough if the device trusts a rogue CA. A corporate MDM profile, a malicious VPN, or mitmproxy with a user-installed root can terminate TLS and read every request. Pinning refuses to complete the handshake unless the server’s public key matches a known fingerprint.

The fingerprint is a SHA-256 hash of the certificate’s Subject Public Key Info (SPKI), not the whole certificate. Pinning to SPKI rather than the cert is what makes routine renewals (new dates, new serial, new signature) survive the pin as long as the keypair stays the same. Ops only rotate keypairs every few years, not every 90 days, so this is the version of pinning that doesn’t constantly break in production.

Compute the pin from the live server with one OpenSSL pipeline:

openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| xxd -p -u -c 256

The SPKI fingerprint lives in .env, treated like any other deployment secret and obfuscated through envied:

@Envied(path: '.env', obfuscate: true)
abstract class Env {
  @EnviedField(varName: 'SSL_FINGERPRINT')
  static final String sslFingerprint = _Env.sslFingerprint;

  @EnviedField(varName: 'SSL_FINGERPRINT_BACKUP', defaultValue: '')
  static final String sslFingerprintBackup = _Env.sslFingerprintBackup;
  ...
}

Shipping a _BACKUP fingerprint lets ops rotate the keypair without a hard cutover. The backup is pre-provisioned one release before the swap. The release after the rotation drops the old primary. Because SPKI pins survive cert renewals, this only fires on actual key changes, which usually means once every few years rather than every 90 days.

Build the pinned client once, reuse everywhere:

// lib/core/network/client/pinned_http_client.dart
class PinnedHttpClient {
  PinnedHttpClient._();

  static final Set<String> _pins = {
    Env.sslFingerprint.toUpperCase().replaceAll(':', ''),
    if (Env.sslFingerprintBackup.isNotEmpty)
      Env.sslFingerprintBackup.toUpperCase().replaceAll(':', ''),
  };

  static IOHttpClientAdapter adapter() => IOHttpClientAdapter(
    createHttpClient: () {
      final client = HttpClient();
      client.badCertificateCallback = (cert, host, port) {
        if (kDebugMode) return true; // let Alice / dev proxies work
        final spki = _spkiOf(cert.der);          // Subject Public Key Info bytes
        final digest = sha256.convert(spki).toString().toUpperCase();
        return _pins.contains(digest);
      };
      return client;
    },
  );

  // Pull the SPKI sub-structure out of the X.509 DER. `badCertificateCallback`
  // is synchronous, so a MethodChannel bridge to PublicKey.getEncoded() /
  // SecKeyCopyExternalRepresentation can't be awaited here. Pure Dart with
  // `package:asn1lib` keeps the call sync and avoids native code on both
  // platforms — X.509's ASN.1 layout (RFC 5280) hasn't moved in 15 years.
  static Uint8List _spkiOf(Uint8List certDer) {
    final cert = ASN1Parser(certDer).nextObject() as ASN1Sequence;
    final tbs = cert.elements[0] as ASN1Sequence;
    // tbsCertificate fields: [0] version, [1] serial, [2] sigAlg, [3] issuer,
    // [4] validity, [5] subject, [6] subjectPublicKeyInfo, ...
    // Index 6 holds for X.509 v3, which every modern CA issues.
    final spki = tbs.elements[6] as ASN1Sequence;
    return spki.encodedBytes;
  }
}

Pin a known cert in a unit test and assert the digest matches the OpenSSL pipeline output. That test catches any drift in asn1lib before users do.

Wire it into every Dio instance. All three of them. The weakest one becomes the bypass:

// lib/core/network/client/dio_helper.dart
static final Dio _dio = Dio(BaseOptions(…))
..httpClientAdapter = PinnedHttpClient.adapter()
..interceptors.addAll([…]);
// refresh_token.interceptor.dart - _tryRefreshToken()
final refreshDio = Dio(BaseOptions(…))
..httpClientAdapter = PinnedHttpClient.adapter();
// refresh_token.interceptor.dart - _retryRequest()
final retryDio = Dio(BaseOptions(…))
..httpClientAdapter = PinnedHttpClient.adapter();

Pin mismatches deserve their own typed event, not a generic network error:

if (error is DioException &&
    (error.type == DioExceptionType.badCertificate ||
     error.error is HandshakeException)) {
  FirebaseCrashlytics.instance.recordError(
    error, error.stackTrace,
    reason: 'ssl_pin_mismatch',
    fatal: false,
  );
}

A spike in ssl_pin_mismatch from a single geography is one of two things: a cert rotation the app missed, or a real MITM in the wild. Both deserve a response.

Pinning pairs naturally with the tamper detection from section 3. A rooted device is the easiest place to bypass pinning (Frida hooks on trust evaluation), but section 3 hard-blocks rooted installs before the first authenticated request fires. And because forced updates from section 7 can ship a new fingerprint alongside a bumped min-version, cert rotation without bricking old installs is a solved problem.

a. Optional: split the pin across two binaries

For builds shipping into high-threat markets, the fingerprint can be stored as two halves: one in envied, one compiled into a stripped NDK .so and read via MethodChannel. They’re XORed together at runtime:

final dartHalf = Env.sslFingerprintHalf;          // hex bytes from envied
final nativeHalf = await NativeBridge.pinHalf();  // hex bytes from NDK
final pin = _xor(dartHalf, nativeHalf).toUpperCase();

Each half is uniform random bytes in isolation. Neither strings on the APK nor a Dart-level dump of _Env reveals anything actionable. The attacker has to defeat both a Dart decompiler and a stripped native binary before they can MITM a single request.

The cost is real: two build pipelines, two rotation steps, platform-specific NDK code in android/ and ios/. Almost never worth the maintenance burden. The option is there when the threat model demands it.

9. Native hardening and obfuscated builds

Everything above is Dart-level. The Dart VM is a comfortable target: snapshots are well-documented, and tools like reFlutter, blutter, and doldrums will happily walk a release app.so and recover class names, method names, and string literals. Without a hardened build, the work in section 1 (envied) is undermined the moment someone runs the right tool on libapp.so.

The build pipeline is where you fix that.

a. Flutter obfuscated release builds

Flutter ships first-party flags for symbol obfuscation. They’re off by default. Turn them on for every release artifact:

# Android
flutter build appbundle --release \
  --obfuscate \
  --split-debug-info=build/symbols/android

# iOS
flutter build ipa --release \
  --obfuscate \
  --split-debug-info=build/symbols/ios

— obfuscate rewrites Dart class and method names in the AOT snapshot. — split-debug-info peels the symbol map out of the binary into a separate file. The shipped .so / .app no longer carries enough metadata for a stack trace to be human-readable.

The trade-off is that crash reports stop being readable too. That’s why — split-debug-info is mandatory, not optional. The symbol files in build/symbols/<platform> go into long-term storage (S3, GCS, whatever the team uses for build artifacts), keyed by version code. Crashlytics dSYM/Mapping upload then resolves obfuscated frames back to source on the dashboard side:

# Android — upload the mapping alongside the bundle
firebase crashlytics:symbols:upload \
  --app=$ANDROID_APP_ID \
  build/symbols/android

# iOS — upload dSYMs from the archive
firebase crashlytics:symbols:upload \
  --app=$IOS_APP_ID \
  build/ios/archive/Runner.xcarchive/dSYMs

Lose the symbols and a crash report becomes a wall of ???. Bake the upload into CI so it can never be skipped.

b. Android: R8 and resource shrinking

— obfuscate handles the Dart side. The Kotlin/Java side and the Android resources need R8.

// android/app/build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
            signingConfig = signingConfigs.getByName("release")
        }
    }
}

R8 minifies, shrinks, and renames Java/Kotlin symbols. isShrinkResources = true drops unreferenced strings, drawables, and layouts so an attacker can’t fish around for hints in the resource table.

The accompanying proguard-rules.pro keeps the classes that reflection touches (Flutter’s plugin registrar, Firebase, anything called from the platform side via MethodChannel):

10. Extra hardening

Two more layers, each self-contained and cheap to land on its own.

a. Biometric lock on resume

Token-based auth protects the API, but it doesn’t protect the open app on an unlocked device. A user walks away for two minutes with an executive overview on screen, and anyone who picks up the phone reads privileged data. Biometric re-auth on resume closes that window.

The BiometricAuthHelper skeleton already exists in the codebase (currently commented out). The full wiring is three pieces.

The helper itself wraps local_auth with a single authenticate() entry point:

// lib/core/helpers/biometric_auth_helper.dart
import 'package:local_auth/local_auth.dart';

class BiometricAuthHelper {
  BiometricAuthHelper._();
  static final LocalAuthentication _auth = LocalAuthentication();

  static Future<bool> isAvailable() async {
    if (!await _auth.isDeviceSupported()) return false;
    if (!await _auth.canCheckBiometrics) return false;
    return (await _auth.getAvailableBiometrics()).isNotEmpty;
  }

  static Future<bool> authenticate({required String localizedReason}) async {
    try {
      if (!await isAvailable()) return true; // fall back gracefully
      return await _auth.authenticate(
        localizedReason: localizedReason,
        options: const AuthenticationOptions(
          stickyAuth: true,
          biometricOnly: false, // allow device PIN as fallback
        ),
      );
    } catch (_) {
      return false;
    }
  }
}

A lifecycle observer watches for backgrounded-then-resumed transitions past a timeout:

// lib/core/helpers/app_lock_observer.dart
class AppLockObserver with WidgetsBindingObserver {
  static const _lockAfter = Duration(minutes: 1);
  DateTime? _backgroundedAt;
  bool _isLocked = false;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) async {
    if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
      _backgroundedAt = DateTime.now();
      return;
    }
    if (state == AppLifecycleState.resumed && _backgroundedAt != null) {
      final elapsed = DateTime.now().difference(_backgroundedAt!);
      if (elapsed >= _lockAfter && !_isLocked) {
        _isLocked = true;
        final ok = await BiometricAuthHelper.authenticate(
          localizedReason: t.security.unlockToContinue,
        );
        _isLocked = false;
        if (!ok) {
          await StorageHelper.clearStorage();
          AppRouter.goRouter.goNamed(AppRoutes.login);
        }
      }
    }
  }
}

Then register the observer once in main.dart after runApp:

WidgetsBinding.instance.addObserver(AppLockObserver());

Some of those choices are worth defending. Locking on every app switch is user-hostile, so we lock on a timeout instead. A one-minute threshold blocks walk-away attacks without annoying tab-switchers. Setting biometricOnly: false lets the device PIN serve as a fallback, so phones without biometrics or with a dirty sensor still work. And a wrong biometric is a session termination, not a retry loop. The refresh token still lives in Keychain or Keystore, so a real user can log back in normally, but a shoulder-surfer can’t brute-force the lock.

Only gate biometric on authenticated sessions. Unauthenticated users have nothing worth locking. The observer should check StorageHelper.getToken() before prompting; I omitted that above for brevity.

b. Screenshot and screen-recording block

Executive overviews, incident details, financial dashboards. None of it should land in the system screenshot store or stream during a screen recording.

Under the hood it flips FLAG_SECURE on Android, which also blocks the app from appearing in the recent-apps thumbnail (a free win), and on iOS attaches an overlay view that hides content during screenshots and obscures the frame buffer while a recording is active. iOS can’t prevent recording, only obscure it; that’s a platform limitation, not a package shortcoming.

// lib/core/helpers/screen_capture_helper.dart
import 'package:screen_protector/screen_protector.dart';

class ScreenCaptureHelper {
  ScreenCaptureHelper._();

  static Future<void> block() async {
    await ScreenProtector.protectDataLeakageOn();           // Android FLAG_SECURE + iOS overlay
    await ScreenProtector.protectDataLeakageWithColor(Colors.black); // iOS app-switcher cover
  }

  static Future<void> allow() async {
    await ScreenProtector.protectDataLeakageOff();
    await ScreenProtector.protectDataLeakageWithColorOff();
  }
}

Wire it per-screen with a mixin so feature code stays clean:

mixin ScreenCaptureBlockMixin<T extends StatefulWidget> on State<T> {
  @override
  void initState() {
    super.initState();
    ScreenCaptureHelper.block();
  }

  @override
  void dispose() {
    ScreenCaptureHelper.allow();
    super.dispose();
  }
}

Then opt in on sensitive screens:

class _ExecutiveOverviewScreenState extends State<ExecutiveOverviewScreen>
with ScreenCaptureBlockMixin {
…
}

Worth saying out loud: make it opt-in, not global. Blocking every screen breaks legitimate workflows like sharing a bug screenshot with support. Pair it with analytics filtering, because screens that set FLAG_SECURE often also need to be excluded from session-replay tooling, and the same mixin is the natural place to toggle both. And be honest with stakeholders. Android can hard-prevent recording. iOS can only detect and obscure. That’s a platform limitation, not something we can engineer around.

Recap

None of the layers above does dramatic work on its own. The point is the pile-up.

envied obfuscates endpoints and pin fingerprints at build time, and .env never gets committed. Once the app is on the device, flutter_secure_storage funnels every secret through Keychain or Keystore. At launch, SafeDevice, the installer allowlist, and emulator detection hard-block tampered installs and feed the verdict back to Crashlytics. Network calls run through interceptors that attach the bearer, deduplicate inflight requests, and single-flight the refresh on 401. Sitting in front of every Dio instance is SHA-256 SPKI pinning, with a backup fingerprint so routine cert renewals don’t brick the app.

The session model is the same idea applied to time. Access tokens are short-lived. Refresh tokens are device-bound. Expiry triggers a proactive refresh before the user notices, and a refresh failure wipes storage and bounces to login. Biometric re-auth gates resume after a timeout. Screen capture is blocked on the surfaces that warrant it. Forced updates keep vulnerable binaries off the live API. Crashlytics carries tamper events and pin mismatches alongside ordinary crashes, so the dashboards the on-call rotation already watches double as the security anomaly pipeline.

Casual attacks aren’t worth the time. Serious ones leave a trail. That’s all the stack is really buying you, but it’s enough.


메타데이터
post_id
fe3b806af59a
slug
how-to-secure-your-flutter-app-fe3b806af59a
url
https://medium.com/@3bdo9320/how-to-secure-your-flutter-app-fe3b806af59a
canonical_url
https://medium.com/@3bdo9320/how-to-secure-your-flutter-app-fe3b806af59a
author_url
https://medium.com/@3bdo9320
status
ok
fetched_at
2026-06-16 19:09:56