De documentatie is alleen in het Engels geschreven. De rest van de site is ook in het Nederlands beschikbaar.
Security design
Threats, the security problems found in v1 and v2, and the concrete measures v3 takes against each.
Bijgewerkt: 18 September 2026
Incico stores exactly the data people most want kept private: home addresses, private phone numbers, birthdays, children’s details. The v3 security design starts from what went wrong before.
Findings in v1 and v2
The inventory of the existing code found the following. None of it is carried into v3; several items need action on the old repositories regardless.
| # | Finding | Where | Action for the old code |
|---|---|---|---|
| F1 | TLS certificate validation disabled unconditionally in the shipped MAUI client | Incico-App .../HttpsClientHandlerService.cs | Withdraw v1 from the stores or hot-fix before any further distribution |
| F2 | Production secrets in source: SQL passwords, storage keys, Service Bus keys, OAuth client secrets, SMTP password, RSA signing keys | Incico-Api, Incico-IS, Incico-Service appsettings and pipelines | Rotate every credential; the repository history is compromised |
| F3 | Google service-account private key (playstore-console@incico-8b116) committed in the v2 folder | IncicoV2/incico2/incico-8b116-*.json | Revoke the key in Google Cloud IAM, create a new one outside any repository |
| F4 | OAuth tokens for Google/Microsoft stored in plaintext columns | SyncService table | Delete with the v1 database |
| F5 | JWT audience validation off; CORS allow-any; account lockout off; password rules bypassed on reset; ROPC grant enabled | Incico-Api, Incico-IS | Retired with v1 |
| F6 | Tokens in browser localStorage on the web client | NotSoSecureStorage.cs | Retired (no web app in v3) |
| F7 | Debug-level logging of full request/response bodies including PII | LoggingMiddleware.cs | Retired |
| F8 | Firebase API keys and release keystore committed | Incico-App | New keystore for v3; old one treated as leaked |
| F9 | v2 authentication entirely simulated: demo credentials pre-filled in the sign-in form, verification codes never checked, social buttons only navigate | IncicoV2 features/auth/signin/useSignInFlow.ts:14-55 | Not shipped; superseded |
| F10 | v2 “Sign out” only navigates; tokens stay in the secure store and the persisted auth state stays signed in | IncicoV2 app/profiles/settings.tsx:36-38 | Not shipped; v3 sign-out wipes cache and secure store (tested) |
| F11 | v2 web build keeps tokens in localStorage; no 401 refresh interceptor; expiresIn never checked | IncicoV2 services/storage/SecureStorage.ts:13-23 | No web app in v3 |
| F12 | v2 passes contact name, e-mail, phone and photo URL as route query parameters between screens | IncicoV2 app/contacts/search.tsx:43-53 | v3 passes ids only |
| F13 | v2 requests RECORD_AUDIO and (via a native module) SYSTEM_ALERT_WINDOW without any feature using them; iOS camera string describes a scanning feature that does not exist | IncicoV2 app.json:30-46 | v3 declares only permissions with a shipped feature and an in-app explanation |
| F14 | v2 logs verification codes and e-mail addresses with console.log, not stripped from release bundles; minifier mangling disabled for production | IncicoV2 useSignInFlow.ts:41, PhonesForm.tsx:314, metro.config.js:9-12 | v3 log allow-list test; R8 obfuscation on |
| F15 | v2 has no account deletion, no privacy policy link, no consent screen | IncicoV2 app-wide | v3 Settings → Privacy, onboarding step 2.4 |
Threat model
Assets: profile details, connection graph, photos, sign-in credentials, push tokens. Adversaries: a curious or malicious connected user trying to see more than shared; a stranger enumerating users; a stolen or rooted phone; a compromised third-party (Google) token; a leaked repository; a malicious app on the same device; Incico operators themselves (data minimisation).
Measures
Identity and sessions
- Supabase Auth with e-mail + password and magic link, plus Google and Apple sign-in. Apple sign-in is mandatory on iOS because Google sign-in is offered.
- Native token flows: Google via Credential Manager (Android) and
ASAuthorizationController(Apple); the app exchanges the provider ID token withsignInWithIdToken. No browser redirect, no custom URL scheme, nothing another app can intercept. - Magic-link and password-reset links open the app through verified app links / universal links on
https://www.incico.eu/auth/.... The website hostsassetlinks.jsonandapple-app-site-association. - Refresh-token rotation on, reuse interval 10 s (Supabase default in Saymail’s config), access token lifetime 1 hour.
- E-mail verification required before onboarding continues. Password policy: minimum 10 characters, checked against the HaveIBeenPwned range API by Supabase Auth’s leaked-password protection.
- Rate limits on sign-in, OTP and password reset at the Auth layer; CAPTCHA (Turnstile) on sign-up and password reset.
- Account lockout is Supabase’s built-in throttling; there is no custom identity server to misconfigure.
- Sessions can be listed and revoked from Settings (“Signed-in devices”) through
list_sessions()andrevoke_session(), which read and delete only the caller’s ownauth.sessionsrows and never return the IP address (A84).
Data access
- RLS default-deny on every table; cross-user reads only via snapshots computed server-side (see Access control).
- Search by hashed exact tokens only, rate-limited and audited. No name-prefix search of strangers; name search matches whole lower-cased words and only on names the owner marked searchable.
- Blocking is honoured in search, invitations and snapshots.
- Photos in a private bucket behind a policy function; no public URLs.
- The service-role key exists only in Supabase’s edge-function secrets and in the deploy script’s environment, never in an app.
On the device
- Session tokens in Android Keystore-backed storage (
EncryptedSharedPreferencesor its successorandroidx.securityDataStore) and the iOS Keychain withkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, exactly like Saymail’sSecretStoreandKeychainwith the same contract: secrets never in the database, logs or analytics. - Google Contacts OAuth uses a public client with PKCE per platform (Android and iOS client ids registered for the app’s signing certificate and bundle id). No client secret in the binary. This fixes the weakness noted in Saymail, where a desktop client secret is embedded.
- Nothing the app stores leaves the device in a cloud backup or a device transfer, the local cache database included (A98). On Android
android:allowBackup="false"turns off cloud backup, and device transfer up to Android 11; from Android 12 it no longer stops a device-to-device transfer, sodata_extraction_rules.xmlstays as a single rule that excludes every storage domain from both cloud backup and device transfer. There is nofullBackupContentfile and no per-file exclusion list to keep in step with new stores. After a transfer the person signs in again and the cache is fetched afresh. On iOS the cache file carriesisExcludedFromBackup. The cache file is protected with the platform’s file encryption. Optionally the cache is wiped when the account signs out. - No screenshots of sensitive screens prevention is applied (a contact card is not a secret in the way a QR pairing code is); this is a deliberate choice.
- Certificate pinning is not used (Supabase rotates certificates); the platform trust store is used unmodified. There is no code path that overrides
TrustManager,HostnameVerifier, or App Transport Security. - Debug and developer menus live in a debug source set /
#if DEBUGonly, as in Saymail. - Release builds are shrunk and obfuscated with R8 and the mapping file is uploaded to Play; crash reports are symbolicated server-side. (This differs from Saymail’s
-dontobfuscate; see the questions page.)
Server side
- Edge functions verify the JWT and re-check ownership; they never trust ids in the body.
- The fan-out call to
push-fanoutis a trigger the repository owns rather than a hosted database webhook, so it is migrated like everything else. Its URL and its shared secret live in Vault (push_fanout_url,push_fanout_secret), never in a migration: the URL differs per project and the secret must never be in git. The function sends the secret asx-webhook-secretand the edge function compares it in constant time. Without both Vault entries the trigger is a silent no-op, so a local stack or a fresh project keeps working and only loses the push. - pg_net’s queue is readable by the client roles at the database level. The extension grants
anonandauthenticatedusage on schemanetand read access tonet.http_request_queue, which means a queued push row, including itsx-webhook-secretheader, is visible to those roles in Postgres. Those grants were made bysupabase_admin, and a migration running aspostgrescannot revoke them: the statements are accepted and change nothing. What keeps the queue out of reach is PostgREST, which serves only the schemas named in[api] schemas—publicandgraphql_public.netmust never be added to that list, and that is the control this risk is accepted on. - Push payloads carry only a notification kind and an id, never contact details; the app fetches the change after the tap or in the background.
- E-mail addresses enter the search index only once verified: anyone can type anyone’s address, and an unverified one would let an impostor be found under somebody else’s name. Phone numbers are indexed without that proof, because SMS verification is out of the launch scope (assumption A10); the day it arrives, one line in
derive_search_tokenschanges. audit_eventsrecords invitations, searches, blocks, deletions and exports with a hashed IP, retained 12 months. A search row records how many tokens arrived and nothing else: storing the tokens would rebuild, one search at a time, exactly the address book the hashing exists to prevent.- Postgres roles:
anoncan call nothing except the Auth endpoints;authenticatedreaches tables under RLS, the column-level update grants and the whitelisted functions; every other function is revoked fromanon, authenticated, public. - Verification codes are never stored.
email_verificationskeeps a random salt andsha256(salt ‖ code), so a leaked backup cannot be replayed, and the comparison is constant-time. - Backups: Supabase daily backups, point-in-time recovery on the production project, 30-day retention. Account deletion removes live rows immediately; backups age out within 30 days. The privacy policy states this.
Supply chain and repository
- No secrets in the repository.
.envat the repo root (gitignored) for the Google client ids per environment, Androidkey.propertiesgitignored, keystore outside the repo, iOS signing via App Store Connect API key on the Mac mini, as in Saymail. - A pre-commit hook runs
gitleakson staged files; CI runs it on every push. - Dependencies pinned via Gradle version catalog and Swift Package Manager
Package.resolved; Dependabot enabled. iOS aims for the Saymail posture of zero third-party dependencies exceptsupabase-swift. - Third-party licences listed in an in-app “Open-source licences” screen (a gap noted in Saymail).
Privacy by design
- Data minimisation: no analytics before consent; typed events only (PostHog EU) with an allow-list test that no property may contain an e-mail, phone or name.
- Diagnostics logs never include detail values: on Android every log call goes through one
AppLogfacade that redacts e-mail addresses, phone numbers, tokens and secure-store key names before logcat or the diagnostics log sees a line (a unit test runs those patterns through it), andtools/log-check.mjsgreps the call sites in CI for templates of sensitive names (Telemetry and privacy, A88). Crash reports carry classes and frames, never an exception’s message (A89). - Data export and account deletion are one tap each in Settings and also reachable from the website’s account page (
/{lang}/account/delete/, A91) for users who lost their phone. That page signs in with an e-mail code through a first-party script and keeps the token insessionStorageonly while the page is open; the two edge functions answer CORS for the website’s origins only, never*. - The app never reads the device address book unless the user connects the “Device contacts” service, and then only with the platform permission dialog.
Store compliance checklist
- Play Data Safety and Apple Privacy Nutrition labels derived from the tables in Telemetry and privacy.
- Apple: Sign in with Apple offered; account deletion in-app;
PrivacyInfo.xcprivacylists UserDefaults reasonCA92.1and collected types Contact Info, Photos (user content), Device ID (for push), all linked to the user, none used for tracking. - Google:
READ_CONTACTS/WRITE_CONTACTSdeclared only with the device-contacts feature and requested at the moment of connecting the service; noQUERY_ALL_PACKAGES, no background location, no SMS permissions.