How We Keep Secrets Out of Source, Builds and Logs
By Mykhailo Boichuk · Chief Technology Officer
In short
BBMM Technologies treats anything compiled into the Flowo app as public, so reusable backend and provider credentials stay on the server and the app receives only public SDK identifiers at build time. Development values live in one gitignored file, release values come from CI secrets, and a validator script fails the release when a value is missing, still a placeholder, or meant for another environment. A scanner checks the full commit history on every pull request.
What counts as a secret in a client app
Anything shipped inside a mobile app can be read by whoever downloads it. An app bundle is an archive, and strings compiled into it can be extracted with ordinary tools. We therefore sort every value into one of two groups before deciding where it lives.
The first group is reusable credentials: a key that lets its holder call a paid provider or sign requests to our backend. These never enter the Flowo app. They stay on the server, and the app proves itself to the backend with a short-lived token tied to device attestation, which our article on rate limiting describes.
The second group is public build configuration. It holds the backend address, the environment name, and the SDK identifiers that analytics and payment vendors design to be embedded in a client. These values are injected when the app is compiled.
Where development values live
Each developer keeps local values in a single env file that git ignores. The file is passed to the Flutter tool with the --dart-define-from-file flag when the app is run. Nothing in the app reads the file at runtime, and it is not listed as a bundled asset, so it cannot travel inside a build.
The ignore rules in the Flowo repository cover more than that one file:
- Every file whose name ends in .env, plus the common .env variants by name.
- A local secrets directory used to stage release values on a build machine.
- The Android signing properties file and any keystore under the app folder.
- A private keys directory.
- A document of live promotional codes, because each code works as a bearer token.
The backend repository ignores the same env patterns and allows one exception, a file marked as an example, which is the place for variable names without values.
CI never sees a developer file. Before any build, the workflow writes the env file as an empty file, so a build on a runner cannot pick up values that happen to be lying on disk.
How a release gets its values
A release build receives its configuration from CI secrets and from no file in the repository. The delivery workflow runs each job inside a named deployment environment, so staging values and production values are stored and granted separately.
The build follows a fixed sequence:
- 1.The validator script checks the environment variables and exits with an error on the first problem.
- 2.A second script checks that the certificate chain of the API host matches the pins compiled into the app.
- 3.A writer script produces a JSON file of defines from an allowlist of keys. A key that is absent from the allowlist cannot reach the compiler.
- 4.The app is built with that file, and the same step deletes the file when the build command returns.
The allowlist has one conditional part. Payment SDK keys are written only when monetization is switched on for that build, and a test runs the writer both ways to confirm that a free build's defines contain no payment key.
Signing material is handled the same way. The Android keystore is stored as an encoded CI secret, decoded to a file for the build, and deleted in that step. The iOS certificate is imported into a temporary keychain in the runner's temp directory.
What the validator rejects
The release environment validator is a Python script of about 190 lines that runs before every release build and stops it on the first failed check. It prints which variable failed and never prints a value.
- A required variable that is empty.
- A value that is still a placeholder. The script holds a list of eleven, including changeme, todo and your_key_here.
- An environment and release channel that do not belong together. Production accepts one channel, and development and staging accept two each.
- Analytics keys for development, staging and production that are not all different.
- A production analytics key equal to the key in the local development file.
- A backend address that is not absolute HTTPS, or that carries credentials, a query string or a fragment.
- A production build whose backend host name contains staging, development or localhost, unless a named override is set.
- Session replay enabled in production without a separate approval flag.
- A build for any channel other than local without certificate pinning enforced.
- A production payment SDK key that belongs to a different store or to a test store.
The placeholder check exists because an emptiness check is easy to pass. A placeholder compiled into a release looks at runtime like a real credential that happens to be wrong.
Tests and scans that guard the rule
The rule that reusable credentials stay out of the app is enforced by a test. It reads every Dart source file in the app and fails when one matches a pattern for a compiled-in backend or provider credential, or for a request-signing header built on the client.
A second test reads the delivery workflow file. A token used to fetch a model for conversion may exist in CI, and the test fails if that token ever appears among the values passed to the Dart compiler.
A secret scanner runs as its own CI job. It checks out the full history and scans it on every pull request, on every push to the integration branch, and once a week on a schedule. The weekly run rescans the history even when nobody has pushed.
The websites
Our three websites are static, so every value compiled into them is public by construction. A browser downloads the whole bundle, and a key inside it can be read from the network panel.
We give the sites only identifiers that vendors publish for client use. They are supplied at build time from CI secrets or CI variables, which keeps them out of the repository and lets a preview build run with none of them. A build without the analytics identifier never loads the analytics library.
Deploy credentials are different. The token that publishes a site to the host is used by the deploy step and is never passed to the build step, so it cannot be compiled into a page.
What we keep out of this article
This article describes practice and leaves out names, values and addresses. We do not list which variables hold which credentials, and we do not describe how production access is granted.
Key takeaways
- Reusable backend and provider credentials never enter the Flowo app, and a test scans every Dart source file to keep it that way.
- Development values live in one gitignored env file passed with --dart-define-from-file, and CI replaces that file with an empty one before every build.
- A writer script builds the release defines from an allowlist, and payment SDK keys are included only when monetization is on for that build.
- The release validator rejects eleven placeholder values, mismatched environment and channel pairs, and analytics keys shared between environments.
- A secret scanner checks the full commit history on every pull request and once a week on a schedule.
- The Flowo logger redacts bearer tokens, URLs, file paths and email addresses before a log line is kept.
Frequently asked questions
- How do you keep API keys out of a Flutter app?
- We keep reusable keys on the server and never compile them into the app. The Flowo app receives only public SDK identifiers and build configuration through --dart-define at build time. A test fails the build if a Dart source file contains a pattern for a compiled-in backend or provider credential.
- Is a .env file safe for secrets?
- A .env file is safe for local development when git ignores it and nothing bundles it. In Flowo the file is passed to the Flutter tool as a source of build-time defines, the app does not read it at runtime, and CI overwrites it with an empty file before building. Release values come from CI secrets.
- What should a release environment check validate?
- Ours checks that required values are present, that none is a known placeholder, and that the environment and release channel belong together. It also requires separate analytics keys per environment, an HTTPS backend address with no embedded credentials, and enforced certificate pinning. The script exits on the first failure and never prints a value.
- Do you need a secrets manager to handle secrets well?
- A dedicated service is not required for the basics. BBMM Technologies uses ignore rules, the secret store built into its CI system, an allowlist for build-time values, a validator script and a secret scanner. Those cover the common leaks: a committed file, a value in the wrong build, and a credential compiled into a client.
References
- Flutter documentation
- BBMM Technologies website
- Flowo website
- OWASP Mobile Application Security Verification Standard
- GitHub Actions documentation: secrets and deployment environments
- Gitleaks secret scanner
About the author
Mykhailo Boichuk
Chief Technology Officer
Mykhailo builds the systems behind the apps. He works on macOS and iOS performance, and on the sync problems that come with software that has to run offline.