bluesky-social/social-app
May 5, 2026
Application: social-app (Bluesky) Document Title: Security Audit Report (Mobile) Date: June 2026 Assessment Scope: React Native / Expo
Generated by Inkwell Forge — automated codebase documentation analysis. Subject matter expert review is recommended before distribution.
This security audit assessed 131 screens of the Bluesky social application, a React Native / Expo client built on the AT Protocol. The assessment was conducted through documentation-based analysis against OWASP MASVS v2.0, OWASP MSTG, OWASP ASVS v4.0.3, NIST SP 800-163 Rev 1, and CWE/SANS Top 25.
Overall Security Posture: Moderate
The application demonstrates a strong foundation in several security-critical areas. The AT Protocol's DID-based authentication model provides inherent CSRF resistance for API calls. The codebase consistently applies content moderation decisions from server-side labeling, uses sanitizeDisplayName and sanitizeHandle across profile rendering surfaces, and employs cleanError to prevent raw server error details from reaching users. The Expo OTA update mechanism is configured with code-signing certificates in production builds, and the Android intent filter uses autoVerify: true for App Links, providing meaningful resistance to deep-link hijacking.
The most significant findings center on three areas. First, the Log screen (/log) renders the application's full in-memory log buffer — which may contain user DIDs, API response fragments, and error details — with no authentication guard at the component level, creating a meaningful information disclosure risk if the route is reachable in production builds. Second, the SharedPreferencesTester E2E screen exposes native device storage read/write operations with no access control, and its presence in production builds would allow any user to probe and manipulate shared preferences. Third, the signup flow stores the user's password in React component state (a useRef) and that state object is logged in its entirety via a debug analytics call, creating a risk of password exposure in analytics pipelines.
Refer to the Finding Inventory for the authoritative record of all findings by severity.
Assessment Limitations: This assessment is based entirely on per-screen codebase documentation. It does not include dynamic application security testing (DAST), static application security testing (SAST), device forensics, binary analysis, or live penetration testing. Findings marked "(documented behavior — verify with penetration testing)" require live testing to confirm exploitability. Findings marked "(not documented — requires security testing to confirm)" require additional investigation.
Primary Framework: OWASP MASVS v2.0 — mobile-specific verification requirements across STOR, CRYPTO, AUTH, NETWORK, PLATFORM, CODE, and RESILIENCE categories.
Supplementary Framework: OWASP MSTG (Mobile Security Testing Guide) — testing methodology and finding classification for React Native applications.
Shared API/Web Framework: OWASP ASVS v4.0.3 — verification requirements for API and server-side concerns.
Weakness Enumeration: CWE/SANS Top 25 — standardized weakness identifiers.
Control Assessment: NIST SP 800-163 Rev 1 — mobile application vetting methodology.
Scope: All 131 screens provided in the documentation.
Limitations: Based on technical documentation review, not dynamic application security testing (DAST), static application security testing (SAST), or device forensics.
| Severity | CVSS Range | Definition | SLA |
|---|---|---|---|
| Critical | 9.0–10.0 | Exploitable vulnerability that allows unauthorized access, data breach, or system compromise | Fix immediately (24h) |
| High | 7.0–8.9 | Significant vulnerability with high probability of exploitation and serious impact | Fix within 1 week |
| Medium | 4.0–6.9 | Moderate vulnerability requiring specific conditions to exploit | Fix within 30 days |
| Low | 0.1–3.9 | Minor security weakness with limited exploitability or impact | Fix within 90 days |
| Info | 0.0 | Security best practice recommendation or hardening suggestion | Consider for future work |
| Metric | Value |
|---|---|
| Screens Assessed | 131 |
| OWASP MASVS Categories Triggered | MASVS-STOR · MASVS-AUTH · MASVS-NETWORK · MASVS-PLATFORM · MASVS-CODE · MASVS-RESILIENCE — 6 of 7 |
| Mobile-specific Risk Areas | On-device storage concerns present · Deep-link validation gaps present · Native module trust concerns present · E2E test screen exposure present |
| Risk Posture | Moderate |
| Findings Distribution | See Finding Inventory below |
| # | Category | Status | Findings | Notes |
|---|---|---|---|---|
| MASVS-STOR | Data Storage Security | Concern | 3 | Search history in AsyncStorage/localStorage; password in component state logged via analytics; log screen exposes in-memory sensitive data |
| MASVS-CRYPTO | Cryptography | Pass | 0 | OTA code-signing configured; no custom crypto observed in screen documentation |
| MASVS-AUTH | Authentication & Session Management | Concern | 3 | Log screen lacks auth guard; E2E tester screen lacks auth guard; signup password logged in debug analytics |
| MASVS-NETWORK | Network Communication | Concern | 1 | Certificate pinning not documented in screen files; ATS/network-security-config not assessable from screen docs |
| MASVS-PLATFORM | Platform Interaction | Concern | 4 | Deep-link parameter validation gaps; E2E screen exposes native storage; decodeURIComponent unguarded; window.location.href redirect in CAPTCHA |
| MASVS-CODE | Code Quality & Build Settings | Concern | 3 | E2E test screen in production build path; console.log removal only in production env; multiple @ts-ignore suppressions |
| MASVS-RESILIENCE | Resilience Against Reverse Engineering | Concern | 1 | No jailbreak/root detection documented; developer mode toggle accessible via long-press gesture |
Status definitions:
| Storage Mechanism | Usage | Data Stored | Encrypted? | Risk | Finding |
|---|---|---|---|---|---|
useStorage (device-level, likely MMKV) |
Search history, account history, developer mode flag, policy NUX state, activity nudge state | Search term strings, profile DIDs, boolean flags | No (MMKV unencrypted by default unless configured) | Medium — search history contains profile DIDs; not tokens but PII-adjacent | SEC-005 |
persisted (AsyncStorage-backed) |
Reminders, last email confirm timestamp | Timestamps, reminder state | No | Low — non-sensitive preference data | None |
BackgroundNotificationHandler native module |
playSoundChat preference |
Boolean | Platform-native (UserDefaults/SharedPreferences) | Low — non-sensitive | None |
SharedPrefs (expo-bluesky-swiss-army) |
E2E test keys: testerString, testerBool, testerNumber, testerSet |
Test fixture data | No | High — E2E screen exposes read/write to native storage with no auth guard | SEC-003 |
React component state (useRef) |
Signup password | Plaintext password string | No (in-memory only) | High — logged via debug analytics call in signup state module | SEC-002 |
| TanStack Query in-memory cache | Session data, profile data, feed data | Profile views, post data, session tokens (via agent) | No (in-memory) | Low — standard in-memory cache, cleared on app restart | None |
Assessment: No use of expo-secure-store or react-native-keychain for sensitive data storage is documented in the screen files. Session tokens are managed by the @atproto/api agent, whose storage mechanism is not visible in screen documentation. [Not documented — WHO: the security lead; WHAT: Does the AT Protocol agent (BskyAgent) store session tokens (accessJwt, refreshJwt) in Keychain/Keystore or in unencrypted AsyncStorage?; WHERE: Insert in Section 5.1 On-Device Storage Security table — add a row for AT Protocol session token storage]
URL Scheme Ownership: From app.config.js, the app registers the custom URI scheme bluesky:// (scheme: 'bluesky'). Custom URI schemes can be registered by any app on the device and are susceptible to hijacking. No caller-identity validation is documented.
Universal Links / App Links:
associatedDomains includes applinks:bsky.app, applinks:staging.bsky.app, appclips:bsky.app, appclips:go.bsky.app. Universal links are tied to domain ownership via apple-app-site-association and resist hijacking. (documented behavior — verify with penetration testing)intentFilters uses scheme: 'https', host: 'bsky.app' with autoVerify: true. App Links with autoVerify are tied to domain ownership via assetlinks.json and resist hijacking. (documented behavior — verify with penetration testing)Linking Config Prefixes: Not directly visible in screen documentation. [Not documented — WHO: the mobile engineering team; WHAT: What are the linking.prefixes entries in the React Navigation linking configuration?; WHERE: Insert in Section 5.2 Deep Link Hijacking Assessment — Linking config prefixes row]
Parameter Tampering: Several screens accept route parameters that are used without explicit validation:
Hashtag screen: tag parameter is decoded with decodeURIComponent without a try/catch guard — a malformed percent-encoded string would throw an unhandled URIError. See SEC-006.ActivityList screen: posts parameter is decoded with decodeURIComponent without a try/catch guard. See SEC-006.Topic screen: topic parameter is decoded with decodeURIComponent without a try/catch guard. See SEC-006.ProfileSearch screen: name and q parameters are passed to API hooks without sanitization at the screen level.StarterPack / StarterPackShort screens: name, rkey, code parameters are passed to API hooks without sanitization.Scheme Ownership Validation: No documentation of caller-identity validation for the bluesky:// custom scheme.
From app.config.js and screen documentation:
| Native Module / Package | Source | Platform | Trust Assessment |
|---|---|---|---|
expo-video |
Expo SDK (well-known) | Both | Low risk |
expo-localization |
Expo SDK (well-known) | Both | Low risk |
expo-web-browser |
Expo SDK (well-known) | Both | Low risk |
react-native-edge-to-edge |
Community (well-known) | Both | Low risk |
@sentry/react-native |
Sentry (well-known) | Both | Low risk — conditional on SENTRY_AUTH_TOKEN |
expo-build-properties |
Expo SDK (well-known) | Both | Low risk |
expo-notifications |
Expo SDK (well-known) | Both | Low risk |
react-native-compressor |
Community | Both | Medium — verify maintenance status and last audit |
@bitdrift/react-native |
Bitdrift (commercial SDK) | Both | Medium — network instrumentation SDK; verify data collection scope |
MCEmojiPicker (custom fork) |
github.com/bluesky-social/MCEmojiPicker |
iOS only | Medium — custom fork of community library; requires security review of fork delta |
expo-bluesky-swiss-army (local module) |
Internal (custom native code) | Both | High — custom native module with SharedPrefs read/write; E2E tester screen exposes this surface. See SEC-003. |
expo-bluesky-gif-view (local module) |
Internal (custom native code) | Both | Medium — custom native GIF renderer; requires security review |
expo-scroll-forwarder (local module) |
Internal (custom native code) | Both | Low — scroll forwarding only |
expo-emoji-picker (local module) |
Internal (custom native code) | Both | Low — emoji selection only |
react-native-reanimated |
Software Mansion (well-known) | Both | Low risk |
react-native-gesture-handler |
Software Mansion (well-known) | Both | Low risk |
react-native-webview |
Community (well-known) | Both | Low risk — used for CAPTCHA; host allowlisting implemented |
expo-image |
Expo SDK (well-known) | Both | Low risk |
expo-contacts |
Expo SDK (well-known) | Both | Low risk — contacts access gated by OS permissions |
expo-location |
Expo SDK (well-known) | Both | Low risk — location access gated by OS permissions |
expo-camera |
Expo SDK (well-known) | Both | Low risk — camera access gated by OS permissions |
react-native-pager-view |
Community (well-known) | Both | Low risk |
@haileyok/bluesky-video |
Internal/community | Both | Medium — video playback management; verify maintenance status |
Custom Native Modules Requiring Security Review:
expo-bluesky-swiss-army — Contains SharedPrefs with read/write/delete operations on native device storage. The E2E tester screen exposes this surface without authentication.expo-bluesky-gif-view — Custom GIF renderer; source URL is passed without validation at the component level.MCEmojiPicker (Bluesky fork) — Custom fork; delta from upstream should be reviewed.| Control | iOS (ATS) | Android (network-security-config) | Status | Notes |
|---|---|---|---|---|
| Certificate pinning | Not documented in screen files | Not documented in screen files | Concern | Cannot assess from screen documentation alone. [Not documented — WHO: the security lead; WHAT: Is certificate pinning configured in the native iOS ATS configuration or Android network-security-config?; WHERE: Insert in Section 5.4 Platform Security Configuration — Certificate pinning row] |
| Cleartext traffic | usesNonExemptEncryption: false in app.config.js (iOS) |
Not documented in screen files | Concern | usesNonExemptEncryption: false indicates the app does not use encryption that requires export compliance declaration, but does not directly indicate cleartext traffic allowance. Android cleartext config not visible. |
| Biometric authentication | Not documented in screen files | Not documented in screen files | Concern | No biometric authentication documented for any screen. [Not documented — WHO: the security lead; WHAT: Is biometric authentication used for any sensitive operations (e.g., app unlock, account switching)?; WHERE: Insert in Section 5.4 — Biometric authentication row] |
| Jailbreak/root detection | Not documented in screen files | Not documented in screen files | Concern | No jailbreak or root detection documented. Developer mode is accessible via a long-press gesture on the version item in About Settings. |
Due to the large number of screens (131), findings are grouped by root cause and cross-referenced to affected screens. Each consolidated finding lists all affected screens in its "Affected Screens" field. Screens with no findings are not listed individually.
/logSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | None enforced at component level |
| Authorization | None |
| Data Sensitivity | High — renders full in-memory application log buffer |
| On-Device Storage | None (reads from in-memory log buffer) |
| Deep Link Target | Yes — Log route in CommonNavigatorParams |
| Native Modules Used | None |
| Attack Surface | High — no auth guard, exposes potentially sensitive runtime data |
| Finding Count | Critical: 1 |
SEC-001: Log Screen Exposes In-Memory Application Log Buffer Without Authentication Guard
| Field | Value |
|---|---|
| Severity | Critical |
| MASVS Category | MASVS-AUTH |
| CWE | CWE-200: Exposure of Sensitive Information to an Unauthorized Actor |
| MASVS Requirement | MASVS-AUTH-1 — The app only allows access to sensitive functionality to authenticated and authorized users |
| Component | LogScreen (/log) |
| Affected Data | Full in-memory application log buffer including user DIDs, API response fragments, error details, and any data logged by logger.error/logger.debug calls throughout the application |
| Platform | Both |
Description: The Log screen renders the application's complete in-memory log buffer (getEntries()) with no authentication check, no role guard, and no conditional rendering based on session state. The screen is registered as 'Log' in CommonNavigatorParams, making it navigable from any authenticated navigator. The documentation explicitly states: "No explicit client-side redirect for unauthenticated users visible in this screen's code; enforcement is expected to occur at the navigation/middleware layer." However, the screen is also accessible from the DevOptions section of Settings, which is gated only by the IS_INTERNAL build flag — a client-side constant. Log entries may contain user DIDs, AT Protocol URIs, error messages from API calls, and metadata from analytics events. The logger.debug('signup', next) call in the signup state module logs the entire wizard state object, which includes the user's password field.
Evidence: Section 14 of the Log screen documentation: "No authentication enforcement: This screen performs no authentication or authorization checks. Any user who can navigate to the /log route will see the full system log without restriction, which is a potential security concern." Section 10: "No access control: The screen is accessible to any user who can reach the route, with no developer/admin gating."
Attack Scenario: An attacker who gains physical access to an unlocked device, or who exploits a navigation vulnerability to reach the Log route, can read the full application log buffer. If the signup flow was recently used, the log may contain the user's plaintext password (logged via s.analytics?.logger.debug('signup', next) in the signup state reducer). Even without the password, the log exposes user DIDs, API error details, and behavioral metadata.
Remediation:
Log screen behind the IS_INTERNAL build flag at the navigator registration level, not just at the UI entry point. Remove the 'Log' route from CommonNavigatorParams in production builds, or add a navigator-level guard that checks IS_INTERNAL before rendering the screen.LogScreen: if !currentAccount, render nothing or redirect to Home.logger.debug and logger.info calls to ensure no sensitive data (passwords, tokens, full state objects) is logged. Specifically, remove or redact the s.analytics?.logger.debug('signup', next) call in the signup state reducer that logs the full wizard state including the password field.Log route is not reachable in production builds.Source Evidence: Log (/log), State (/signup/state)
/e2e/shared-preferences-testerSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | None enforced at component level |
| Authorization | None |
| Data Sensitivity | High — exposes native device storage read/write/delete |
| On-Device Storage | SharedPrefs native module (UserDefaults/SharedPreferences) |
| Deep Link Target | Yes — registered in navigator |
| Native Modules Used | expo-bluesky-swiss-army (SharedPrefs) |
| Attack Surface | High — no auth guard, exposes native storage operations |
| Finding Count | High: 1 |
SEC-003: E2E Test Screen Exposes Native Device Storage Operations Without Authentication
| Field | Value |
|---|---|
| Severity | High |
| MASVS Category | MASVS-PLATFORM |
| CWE | CWE-284: Improper Access Control |
| MASVS Requirement | MASVS-PLATFORM-1 — The app only requests the necessary permissions |
| Component | SharedPreferencesTesterScreen (/e2e/shared-preferences-tester) |
| Affected Data | Native device storage (UserDefaults on iOS, SharedPreferences on Android) — any key-value pairs accessible via the SharedPrefs module |
| Platform | Both |
Description: The SharedPreferencesTesterScreen is an E2E testing utility that exposes read, write, and delete operations on native device storage via the expo-bluesky-swiss-army SharedPrefs module. The screen is registered in the navigator with no authentication guard and no build-flag gating at the component level. The documentation states: "No explicit role-based access control or authentication checks are implemented within the screen's source code itself." The screen is accessible at the /e2e/shared-preferences-tester route. If this screen is present in production builds, any user who can navigate to this route can read and write arbitrary key-value pairs in the app's native storage namespace, potentially overriding security-relevant preferences (e.g., playSoundChat, developer mode flags, or any other key stored by the app).
Evidence: Section 2 of the Shared Preferences Tester documentation: "No explicit role-based access control or authentication checks are implemented within the screen's source code itself." Section 14: "The primary security concern is that this screen should not be accessible in production builds."
Attack Scenario: A user navigates to /e2e/shared-preferences-tester in a production build. They use the "Set String" button to write an arbitrary value to testerString, then use the native module's setValue method (exposed through the screen) to overwrite application preferences. Depending on what keys the SharedPrefs module can access, this could allow manipulation of notification preferences, developer mode flags, or other security-relevant settings.
Remediation:
SharedPreferencesTesterScreen route registration from all non-E2E build configurations. Gate the route behind a build-time flag (e.g., IS_E2E_BUILD) that is false in production./e2e/ route namespace is not reachable in production builds.Source Evidence: Shared Preferences Tester (/e2e/shared-preferences-tester)
/signup/stateSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | N/A (pre-authentication flow) |
| Authorization | N/A |
| Data Sensitivity | Critical — handles plaintext password |
| On-Device Storage | React component state (in-memory) |
| Deep Link Target | No |
| Native Modules Used | None |
| Attack Surface | High — password logged via debug analytics |
| Finding Count | High: 1 |
SEC-002: Signup Wizard State Including Plaintext Password Logged via Debug Analytics
| Field | Value |
|---|---|
| Severity | High |
| MASVS Category | MASVS-STOR |
| CWE | CWE-312: Cleartext Storage of Sensitive Information |
| MASVS Requirement | MASVS-STOR-1 — The app securely stores sensitive data |
| Component | Signup state reducer (/signup/state) |
| Affected Data | User's plaintext password during account creation |
| Platform | Both |
Description: The signup state module's reducer function calls s.analytics?.logger.debug('signup', next) on every state transition, logging the complete SignupState object. This object includes the password field, which holds the user's plaintext password as entered during account creation. The analytics context is injected into the wizard state and may route to an external analytics service. Even if the analytics service is internal, logging plaintext passwords violates MASVS-STOR-1 and creates a risk of credential exposure in log aggregation systems, analytics dashboards, or any system that processes the debug log stream.
Evidence: Section 10 of the State (/signup/state) documentation: "Password Logged in Debug Output: s.analytics?.logger.debug('signup', next) logs the entire state object after every action, which includes the password field in plaintext. This is a potential security/privacy concern if the analytics logger transmits data externally." Section 17 confirms this as a known issue.
Attack Scenario: The analytics service receives a debug log event containing { password: "user_plaintext_password", email: "user@example.com", ... }. An attacker who gains access to the analytics dashboard, log aggregation system, or analytics data export can extract user credentials. Even without external access, this creates an insider threat risk.
Remediation:
s.analytics?.logger.debug('signup', next) call from the signup state reducer entirely, or replace it with a redacted version that excludes the password field: s.analytics?.logger.debug('signup', { ...next, password: '[REDACTED]' }).logger.debug calls in the signup flow to ensure no sensitive fields are logged.useRef that is never included in the state object passed to the reducer, keeping it out of the state snapshot entirely.Source Evidence: State (/signup/state)
decodeURIComponent Without Error GuardSecurity Profile (consolidated across affected screens):
| Aspect | Assessment |
|---|---|
| Authentication | Varies by screen |
| Data Sensitivity | Medium — route parameter handling |
| Deep Link Target | Yes — all affected screens accept deep-linked parameters |
| Attack Surface | Medium — malformed deep links can crash affected screens |
| Finding Count | Medium: 1 (consolidated) |
SEC-006: Unguarded decodeURIComponent on Deep-Link Route Parameters
| Field | Value |
|---|---|
| Severity | Medium |
| MASVS Category | MASVS-PLATFORM |
| CWE | CWE-20: Improper Input Validation |
| MASVS Requirement | MASVS-PLATFORM-2 — The app validates all inputs from external sources |
| Component | HashtagScreen (/hashtag), ActivityList (/notifications/activity-list), TopicScreen (/topic) |
| Affected Data | Application stability; potential for denial-of-service via crafted deep links |
| Platform | Both |
Description: Three screens call decodeURIComponent() on route parameters without wrapping the call in a try/catch block. If a deep link delivers a malformed percent-encoded string (e.g., %GG, %), decodeURIComponent throws a URIError, which propagates as an unhandled exception and crashes the component tree. This can be triggered by a malicious actor crafting a deep link URL and sharing it with a target user. The ActivityList screen documentation explicitly notes: "If the posts parameter contains an invalid percent-encoded sequence (e.g., %GG), a URIError will be thrown and crash the screen."
Evidence: Section 14 of Activity List: "Unguarded decodeURIComponent: The call decodeURIComponent(posts) is not wrapped in a try/catch block." Section 17 of Hashtag: "Author param not sanitized for API query: sanitizeHandle is applied to author only for display in the subtitle. The raw author value is used directly in the from:<author> search query string." Section 17 of Topic: "@ts-ignore on desktopFixedHeight" (separate issue, but the decodeURIComponent pattern is documented in Section 4).
Attack Scenario: An attacker crafts a deep link such as bsky.app/hashtag/%GG or bsky.app/topic/%GG and shares it with a target user via a social post or direct message. When the user taps the link, the app navigates to the affected screen, decodeURIComponent('%GG') throws a URIError, and the screen crashes. If no error boundary is present, the crash may propagate to the root of the app.
Remediation:
decodeURIComponent calls on route parameters in try/catch blocks:
let decodedTag: string;
try {
decodedTag = decodeURIComponent(tag);
} catch {
decodedTag = tag; // fallback to raw value
}HashtagScreen (the tag parameter), ActivityList (the posts parameter), and TopicScreen (the topic parameter).Source Evidence: Hashtag (/hashtag), Activity List (/notifications/activity-list), Topic (/topic)
/search/shellSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | Mixed — some features gated by hasSession |
| Authorization | Session-based |
| Data Sensitivity | Medium — search history stored per account DID |
| On-Device Storage | useStorage (device-level, likely MMKV) |
| Deep Link Target | Yes |
| Native Modules Used | None |
| Attack Surface | Medium |
| Finding Count | Medium: 1 |
SEC-005: Search History Stored in Unencrypted Device Storage
| Field | Value |
|---|---|
| Severity | Medium |
| MASVS Category | MASVS-STOR |
| CWE | CWE-312: Cleartext Storage of Sensitive Information |
| MASVS Requirement | MASVS-STOR-1 — The app securely stores sensitive data |
| Component | SearchScreenShell (/search/shell) |
| Affected Data | Search term history (up to 6 strings), recently viewed profile DIDs (up to 10) |
| Platform | Both |
Description: The Search Shell screen stores search term history and recently viewed profile DIDs in device storage via useStorage(account, [currentAccount?.did ?? 'pwi', 'searchTermHistory']) and useStorage(account, [currentAccount?.did ?? 'pwi', 'searchAccountHistory']). The useStorage hook is described as using a device-level storage mechanism (likely MMKV or AsyncStorage). Neither MMKV nor AsyncStorage encrypts data by default. On a rooted/jailbroken device, or via a backup extraction, an attacker could read the user's search history and recently viewed profiles. While search terms are not as sensitive as credentials, they reveal behavioral patterns and the profiles a user has been viewing.
Evidence: Section 12 of Shell (/search/shell): "useStorage(account, [did, 'searchTermHistory']): Stores up to 6 recent search term strings, keyed by account DID. Falls back to 'pwi' key for unauthenticated users." and "useStorage(account, [did, 'searchAccountHistory']): Stores up to 10 recently viewed profile DIDs, keyed by account DID."
Attack Scenario: On a rooted Android device, an attacker uses ADB to pull the app's data directory and reads the MMKV or AsyncStorage database file. The search history reveals what topics and users the victim has been searching for, which could be used for social engineering, stalking, or blackmail.
Remediation:
expo-secure-store or react-native-keychain for sensitive data.Source Evidence: Shell (/search/shell)
/signup/step-captcha/captcha-web-view.webSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | N/A (pre-authentication) |
| Authorization | N/A |
| Data Sensitivity | Medium — handles CAPTCHA authorization code |
| Deep Link Target | No |
| Native Modules Used | None (web only) |
| Attack Surface | Medium — iframe-based CAPTCHA with redirect detection |
| Finding Count | Medium: 1 |
SEC-007: CAPTCHA Authorization Code Passed Without Format Validation
| Field | Value |
|---|---|
| Severity | Medium |
| MASVS Category | MASVS-PLATFORM |
| CWE | CWE-20: Improper Input Validation |
| MASVS Requirement | MASVS-PLATFORM-2 — The app validates all inputs from external sources |
| Component | CaptchaWebView (/signup/step-captcha/captcha-web-view.web) |
| Affected Data | CAPTCHA authorization code passed to the signup API |
| Platform | iOS (web context) |
Description: The CaptchaWebView component extracts the code query parameter from the CAPTCHA redirect URL and passes it directly to the onSuccess(code) callback without any format validation. The component validates the state parameter (CSRF check) and checks that code is non-null, but does not validate that code conforms to the expected format (e.g., a specific length, character set, or pattern). A malicious CAPTCHA provider or a man-in-the-middle who can inject a redirect could supply an arbitrary string as the code value. The documentation notes: "The code value extracted from the redirect URL is passed directly to onSuccess without any format validation (e.g., checking it matches an expected pattern). This is noted as a potential improvement area."
Evidence: Section 17 of Captcha Web View: "code value passed without sanitization: The code extracted from the redirect URL is passed directly to onSuccess without any format validation."
Attack Scenario: A network-level attacker (e.g., on a shared Wi-Fi network without TLS pinning) intercepts the CAPTCHA redirect and replaces the code value with a crafted string. The crafted code is passed to the signup API, which may accept it or return a specific error that reveals information about the expected format.
Remediation:
code parameter before passing it to onSuccess. Validate against the expected format (consult hCaptcha documentation for the expected code format — typically an alphanumeric string of a specific length).if (!/^[a-zA-Z0-9_-]{10,100}$/.test(code)) { onError({error: 'Invalid code format'}); return; }Source Evidence: Captcha Web View.web (/signup/step-captcha/captcha-web-view.web)
/settings/account-settingsSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | Required |
| Authorization | Session-based; App Password restriction for deactivation |
| Data Sensitivity | High — handles account deletion, password reset, handle changes |
| On-Device Storage | None |
| Deep Link Target | No |
| Native Modules Used | None |
| Attack Surface | High — destructive account operations |
| Finding Count | Low: 1 |
SEC-008: Account Deletion Error Handling Resets Form State Without Rollback Notification
| Field | Value |
|---|---|
| Severity | Low |
| MASVS Category | MASVS-AUTH |
| CWE | CWE-755: Improper Handling of Exceptional Conditions |
| MASVS Requirement | MASVS-AUTH-2 — The app informs users of authentication activities |
| Component | DeleteAccountDialog in Account Settings (/settings/account-settings) |
| Affected Data | Account deletion confirmation code and password |
| Platform | Both |
Description: When the account deletion mutation fails, the DeleteAccountDialog resets confirmCode and password fields and returns the user to Step.VERIFY_CODE. However, the error message is displayed via Admonition and the user must re-enter their credentials. This is a minor UX/security concern: the silent reset of credentials without a clear "your deletion attempt failed" notification could confuse users into thinking the deletion succeeded. More significantly, the confirmCode.replace(WHITESPACE_RE, '') stripping is applied at submission time, which is correct, but the error recovery path does not clearly communicate to the user that their account was NOT deleted.
Evidence: Section 10 of Account Settings: "Deletion errors: Cleaned via useCleanError() hook; displayed in Admonition; resets form state and returns to Step.VERIFY_CODE."
Attack Scenario: A user attempts to delete their account. The API call fails due to a transient network error. The form resets silently. The user, believing the deletion succeeded, abandons the app. Their account remains active and potentially accessible to others.
Remediation:
Source Evidence: Account Settings (/settings/account-settings)
/login/login-formSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | N/A (authentication entry point) |
| Authorization | N/A |
| Data Sensitivity | Critical — handles credentials |
| On-Device Storage | useRef (in-memory only) |
| Deep Link Target | No |
| Native Modules Used | None |
| Attack Surface | High — credential entry |
| Finding Count | Low: 1 |
SEC-009: Login Error Classification Uses Fragile String Matching
| Field | Value |
|---|---|
| Severity | Low |
| MASVS Category | MASVS-AUTH |
| CWE | CWE-390: Detection of Error Condition Without Action |
| MASVS Requirement | MASVS-AUTH-2 — The app informs users of authentication activities |
| Component | LoginForm (/login/login-form) |
| Affected Data | Authentication error classification |
| Platform | Both |
Description: The login form classifies server errors using string matching: errMsg.includes('Token is invalid'), errMsg.includes('Authentication Required'), errMsg.includes('Invalid identifier or password'). If the AT Protocol server changes its error message wording, the client-side error categorization will silently break and fall through to the generic error handler. This is a code quality and resilience concern rather than an immediate security vulnerability, but it could result in users receiving misleading error messages (e.g., a 2FA error displayed as a generic error) that impede their ability to authenticate.
Evidence: Section 17 of Login Form: "Error discrimination by string matching: Error handling uses errMsg.includes('Token is invalid'), errMsg.includes('Authentication Required'), etc. This is fragile — if the ATProto server changes its error message wording, the client-side error categorization will silently break."
Attack Scenario: The AT Protocol server updates its error messages. Users attempting to log in with an invalid 2FA token receive a generic "Something went wrong" error instead of the specific "Invalid 2FA confirmation code" message, causing confusion and support burden.
Remediation:
instanceof AuthFactorTokenRequiredError).Source Evidence: Login Form (/login/login-form)
/settings/about-settingsSecurity Profile:
| Aspect | Assessment |
|---|---|
| Authentication | Required |
| Authorization | Developer mode gated by long-press gesture |
| Data Sensitivity | Low — diagnostic information |
| On-Device Storage | Device storage (developer mode flag) |
| Deep Link Target | No |
| Native Modules Used | expo-updates, expo-clipboard, expo-file-system, expo-image |
| Attack Surface | Low — settings screen |
| Finding Count | Low: 1 |
SEC-010: Developer Mode Accessible via Undocumented Long-Press Gesture (Security Through Obscurity)
| Field | Value |
|---|---|
| Severity | Low |
| MASVS Category | MASVS-RESILIENCE |
| CWE | CWE-656: Reliance on Security Through Obscurity |
| MASVS Requirement | MASVS-RESILIENCE-1 — The app implements anti-tampering mechanisms |
| Component | About Settings (/settings/about-settings) |
| Affected Data | Developer mode features including OTA update controls and demo mode |
| Platform | Both |
Description: Developer mode is enabled by a long-press gesture on the version item in the About Settings screen. This is security through obscurity — any user who discovers the gesture can enable developer mode, which exposes OTA update controls (Updates.fetchUpdateAsync(), Updates.reloadAsync()) and demo mode. While OTA updates are gated by Expo's code-signing in production, the ability for any user to trigger an OTA update check and reload is a potential attack surface if the code-signing verification is bypassed or if the update server is compromised.
Evidence: Section 6 of About Settings: "Version item — Long press: Toggles devModeEnabled; shows 'Developer mode enabled/disabled' toast." Section 14: "Developer options isolation: DevOptions is only compiled/shown in IS_INTERNAL builds, preventing exposure of debug tooling in production." However, the About Settings screen itself is not IS_INTERNAL-gated, and the long-press gesture enables developer mode in any build.
Attack Scenario: A user discovers the long-press gesture (e.g., from a blog post or social media). They enable developer mode and use the OTA update controls to trigger a reload. In a scenario where the Expo update server is compromised or the code-signing certificate is stolen, this could allow loading of malicious JavaScript bundles.
Remediation:
IS_INTERNAL — in production builds, the long-press should have no effect.codeSigningCertificate configuration in app.config.js is correctly applied in production builds.Source Evidence: About Settings (/settings/about-settings)
| ID | Screen | Title | Severity | MASVS | CWE | MASVS Req | Platform | Remediation Phase |
|---|---|---|---|---|---|---|---|---|
| SEC-001 | Log (/log) |
Log Screen Exposes In-Memory Application Log Buffer Without Authentication Guard | Critical | MASVS-AUTH | CWE-200 | MASVS-AUTH-1 | Both | Immediate |
| SEC-002 | State (/signup/state) |
Signup Wizard State Including Plaintext Password Logged via Debug Analytics | High | MASVS-STOR | CWE-312 | MASVS-STOR-1 | Both | Short-Term |
| SEC-003 | Shared Preferences Tester (/e2e/shared-preferences-tester) |
E2E Test Screen Exposes Native Device Storage Operations Without Authentication | High | MASVS-PLATFORM | CWE-284 | MASVS-PLATFORM-1 | Both | Short-Term |
| SEC-005 | Shell (/search/shell) |
Search History Stored in Unencrypted Device Storage | Medium | MASVS-STOR | CWE-312 | MASVS-STOR-1 | Both | Medium-Term |
| SEC-006 | Multiple — Affected Screens: Hashtag (/hashtag), Activity List (/notifications/activity-list), Topic (/topic) |
Unguarded decodeURIComponent on Deep-Link Route Parameters |
Medium | MASVS-PLATFORM | CWE-20 | MASVS-PLATFORM-2 | Both | Medium-Term |
| SEC-007 | Captcha Web View.web (/signup/step-captcha/captcha-web-view.web) |
CAPTCHA Authorization Code Passed Without Format Validation | Medium | MASVS-PLATFORM | CWE-20 | MASVS-PLATFORM-2 | iOS | Medium-Term |
| SEC-008 | Account Settings (/settings/account-settings) |
Account Deletion Error Handling Resets Form State Without Rollback Notification | Low | MASVS-AUTH | CWE-755 | MASVS-AUTH-2 | Both | Backlog |
| SEC-009 | Login Form (/login/login-form) |
Login Error Classification Uses Fragile String Matching | Low | MASVS-AUTH | CWE-390 | MASVS-AUTH-2 | Both | Backlog |
| SEC-010 | About Settings (/settings/about-settings) |
Developer Mode Accessible via Undocumented Long-Press Gesture | Low | MASVS-RESILIENCE | CWE-656 | MASVS-RESILIENCE-1 | Both | Backlog |
The following items require live penetration testing, device forensics, or additional documentation to confirm. They are not counted in the Finding Inventory severity totals.
NV-001: AT Protocol Session Token Storage Mechanism
@atproto/api agent pattern — verify against native build files)BskyAgent store accessJwt and refreshJwt in Keychain/Keystore (encrypted) or in AsyncStorage (unencrypted)?NV-002: Certificate Pinning Implementation
app.config.js)network-security-config.xml.NV-003: bluesky:// Custom URI Scheme Hijacking
app.config.js scheme: 'bluesky' — verify with penetration testing)bluesky:// URI scheme and intercept deep links intended for the Bluesky app?bluesky:// and verify whether the OS presents an app chooser or routes to the malicious app.NV-004: Jailbreak/Root Detection Effectiveness
NV-005: OTA Update Code-Signing Enforcement
app.config.js — verify with penetration testing)codeSigningCertificate in app.config.js correctly enforced in production builds? Can an unsigned or differently-signed bundle be loaded?| Category | Topic | Requirements Assessed | Pass | Fail | N/A | Compliance |
|---|---|---|---|---|---|---|
| MASVS-STOR | Data Storage | 4 | 2 | 2 | 0 | 50% — Limited Assessment (session token storage not visible in screen docs) |
| MASVS-CRYPTO | Cryptography | 2 | 2 | 0 | 0 | 100% — Limited Assessment (OTA code-signing documented; no custom crypto in screens) |
| MASVS-AUTH | Authentication | 5 | 2 | 3 | 0 | 40% — Log screen and E2E screen lack auth guards; password logged |
| MASVS-NETWORK | Network Communication | 2 | 0 | 0 | 2 | Limited Assessment — certificate pinning and cleartext config not assessable from screen docs |
| MASVS-PLATFORM | Platform Interaction | 5 | 2 | 3 | 0 | 40% — Deep-link parameter validation gaps; E2E screen exposes native storage |
| MASVS-CODE | Code Quality | 4 | 2 | 2 | 0 | 50% — E2E screen in production path; fragile error classification |
| MASVS-RESILIENCE | Resilience | 3 | 1 | 1 | 1 | 33% — No jailbreak detection documented; developer mode via gesture |
Note: "Limited Assessment" indicates that the available documentation does not provide sufficient detail to fully assess the requirement. Live testing is required for complete compliance determination.
Log screen behind IS_INTERNAL at the navigator registration level; remove the password field from the signup state debug log call. — Effort: 1–2 dayss.analytics?.logger.debug('signup', next) call in the signup state reducer to prevent password logging. — Effort: 0.5 daysSharedPreferencesTesterScreen route from all non-E2E build configurations; gate behind a build-time E2E flag. — Effort: 1 daydecodeURIComponent calls on route parameters in try/catch blocks across HashtagScreen, ActivityList, and TopicScreen; add error boundaries. — Effort: 1–2 dayscode parameter in CaptchaWebView. — Effort: 0.5 daysIS_INTERNAL in production builds. — Effort: 0.5 daysPhase 1 Total Effort Estimate: 1–2 days Phase 2 Total Effort Estimate: 1.5 days Phase 3 Total Effort Estimate: 4.5–7.5 days Phase 4 Total Effort Estimate: 2 days
Pattern 1: AT Protocol DID-Based Authentication with Inherent CSRF Resistance
useAgent() and useSession()Authorization headers (not cookies), traditional CSRF attacks are not applicable. The stateParam validation in the CAPTCHA flow provides additional CSRF-style protection for the OAuth-like code exchange.Pattern 2: Consistent Content Moderation Enforcement at Render Time
ProfileHeaderStandard, ProfileHeaderLabeler, ChatListItem, PostFeed, SuggestedProfileCard, etc.)moderateProfile() and ModerationDecision.ui() results before rendering any user-generated content. Blurred, hidden, or warned content is handled uniformly across all surfaces. The moderation.ui('avatar').blur && modui.noOverride pattern correctly prevents lightbox opening for moderation-restricted content.Pattern 3: OTA Update Code-Signing in Production
app.config.js — updates.codeSigningCertificate and updates.codeSigningMetadataUPDATES_ENABLED = IS_TESTFLIGHT || IS_PRODUCTION). This ensures that only bundles signed with the application's private key can be loaded, preventing malicious OTA bundle injection.Pattern 4: Android App Links with autoVerify: true
app.config.js — android.intentFiltersautoVerify: true with scheme: 'https' and host: 'bsky.app'. This configures Android App Links, which are verified against the domain's assetlinks.json file and cannot be hijacked by other apps (unlike custom URI schemes). This is the recommended approach for deep-link security on Android.assetlinks.json file at https://bsky.app/.well-known/assetlinks.json is correctly configured and maintained. Extend App Links to cover all deep-linked routes.Pattern 5: Input Sanitization for Display Names and Handles
sanitizeDisplayName() and sanitizeHandle()sanitizeDisplayName() and sanitizeHandle() before rendering user-controlled profile data. The cleanError() utility prevents raw server error messages from being displayed to users. These patterns reduce the risk of display-layer injection and information disclosure.sanitizeDisplayName and sanitizeHandle are applied in all new profile-rendering components. Add linting rules to flag direct rendering of profile.displayName or profile.handle without sanitization.AsyncStorage — A React Native key-value storage system that persists data to the device's file system in plaintext. Not encrypted by default. Deprecated in favor of community alternatives.
AT Protocol (atproto) — The decentralized social networking protocol developed by Bluesky. Defines lexicons (schemas) for posts, feeds, actors, and moderation. All API calls use XRPC over HTTPS.
ATS (App Transport Security) — An iOS security feature that enforces HTTPS for all network connections. Configured in the app's Info.plist.
Certificate Pinning — A security technique that restricts which TLS certificates an app will accept, preventing MITM attacks even with rogue CA certificates.
CWE (Common Weakness Enumeration) — A community-developed list of software and hardware weakness types maintained by MITRE.
CSRF (Cross-Site Request Forgery) — An attack that tricks a user's browser into making authenticated requests to a web application without the user's knowledge.
Deep Link — A URL that navigates directly to a specific screen within a mobile application. Can use custom URI schemes (bluesky://) or universal/app links (https://bsky.app/...).
DID (Decentralized Identifier) — A globally unique, persistent identifier for an AT Protocol account (e.g., did:plc:abc123). Used as the canonical identity key.
Expo — A framework and platform for React Native applications. Provides managed workflow, EAS Build, and OTA updates.
IDOR (Insecure Direct Object Reference) — A vulnerability where an attacker can access objects by manipulating references (e.g., IDs) in requests.
Jailbreak Detection — A technique to detect whether an iOS device has been jailbroken, which would allow bypassing OS security controls.
JSI (JavaScript Interface) — A React Native architecture component that allows direct synchronous communication between JavaScript and native code.
Keychain — iOS secure storage for sensitive data (passwords, tokens, certificates). Data is encrypted and protected by the device's secure enclave.
Keystore — Android secure storage for cryptographic keys. Used to protect sensitive data with hardware-backed encryption on supported devices.
MASVS (Mobile Application Security Verification Standard) — OWASP's standard for mobile application security, defining security requirements across seven categories.
MMKV — A high-performance key-value storage library for React Native. Not encrypted by default; encryption requires explicit configuration with a key.
MSTG (Mobile Security Testing Guide) — OWASP's guide for testing mobile application security.
Native Module — A React Native component that bridges JavaScript to platform-specific (Swift/Kotlin) code.
OTA (Over-The-Air) Update — A mechanism to update an app's JavaScript bundle without going through the App Store/Play Store review process. Expo provides this via EAS Update.
PDS (Personal Data Server) — In the AT Protocol, the server that hosts a user's data repository.
Root Detection — A technique to detect whether an Android device has been rooted, which would allow bypassing OS security controls.
SecureStore — Expo's API for accessing the platform's secure storage (Keychain on iOS, Keystore on Android).
SSRF (Server-Side Request Forgery) — An attack where an attacker can cause the server to make requests to internal or external resources.
Turbo Module — The next-generation React Native native module system, providing improved performance and type safety.
Universal Link — An iOS mechanism that associates a domain with an app, allowing HTTPS URLs to open the app directly. Verified via apple-app-site-association file.
App Link — The Android equivalent of Universal Links. Verified via assetlinks.json file on the domain.
URI Scheme — A custom URL scheme (e.g., bluesky://) that can be registered by any app on the device. Susceptible to hijacking.
XRPC — The remote procedure call protocol used by AT Protocol. Queries use GET; procedures (writes) use POST.
XSS (Cross-Site Scripting) — An attack where malicious scripts are injected into web pages viewed by other users. Less applicable in React Native (no DOM) but relevant for WebView components.
Managed Workflow — An Expo project configuration where Expo manages the native build process. The app in this assessment uses a bare/custom workflow based on the presence of app.config.js and custom plugins.
Bare Workflow — An Expo project configuration where the developer has full control over the native code. Allows custom native modules and plugins.
Critical — CVSS 9.0–10.0. Exploitable vulnerability allowing unauthorized access, data breach, or system compromise. Fix immediately (24h).
High — CVSS 7.0–8.9. Significant vulnerability with high probability of exploitation and serious impact. Fix within 1 week.
Medium — CVSS 4.0–6.9. Moderate vulnerability requiring specific conditions to exploit. Fix within 30 days.
Low — CVSS 0.1–3.9. Minor security weakness with limited exploitability or impact. Fix within 90 days.
Info — CVSS 0.0. Security best practice recommendation or hardening suggestion. Consider for future work.