At Streamd, we believe in radical transparency when it comes to security. This report documents all security findings from our audits, how they were resolved, and what we're doing to keep your data safe.
Last updated: April 15, 2026 · Next audit scheduled: Q3 2026
Any authenticated user could forcibly assign any other user as a co-parent on their family, granting the victim full read/write access to child account data without approval.
Replaced immediate account linking with an invite/accept flow. Invites expire after 48 hours and require explicit acceptance by the invitee.
Six signaling actions required no authentication. Attackers could spoof stream identity, tamper with metadata, and disrupt streaming without auth.
All signaling actions now require valid session. Stream ownership verified before any write operations.
Non-atomic read-check-write pattern allowed concurrent requests to activate multiple paid features for the cost of one.
Added per-child distributed lock using Redis SET NX. All conditions re-verified after lock acquisition with automatic lock release.
Check-then-use pattern allowed concurrent completion requests to award duplicate points for a single chore.
Added per-task distributed lock. Task status re-checked after lock acquisition to ensure idempotency.
Non-atomic relationship creation allowed concurrent follow requests to corrupt follower/following counters.
Replaced existence check with Redis SET NX for atomic relationship creation. Added per-relationship lock for unfollow operations.
Device fingerprinting functions directly trusted X-Forwarded-For and X-Real-IP headers without validation. Attackers could spoof IP addresses to poison parental control device mappings, causing legitimate users from the same spoofed IP to be incorrectly flagged as using a child's locked device.
Modified getDeviceId() and getIPAddresses() to prioritize runtime-provided request.ip (set by platform/CDN, unspoofable). Client headers are now only used as fallback in development mode (NODE_ENV !== 'production').
GET endpoints for messages/fetch and notifications/followers performed state mutations (marking read, clearing notifications). SameSite=Lax cookies allowed cross-site triggers.
Separated read from state-changing operations. GET endpoints are now read-only. Added POST/DELETE endpoints for mutations.
The child account login flow created device locks on first login but failed to enforce them on subsequent logins. After initial device registration, children could log in from any device without restriction, completely bypassing parental device controls.
Added device lock enforcement to the child account login flow. After first login, all subsequent logins verify device fingerprint, IPv4, IPv6 prefix, or MAC address matches the locked device. Login is rejected if no lock type matches, requiring parent approval for new devices.
Non-atomic check-then-set pattern for username/email uniqueness allowed concurrent requests to create accounts with duplicate credentials.
Replaced two-step check+set with atomic Redis SET NX. Email and username claims now atomic with rollback on partial failure.
VOD webhook endpoint accepted video.asset.ready events without signature verification, allowing attackers to tamper with VOD metadata and trigger unauthorized deletions.
Implemented Mux webhook signature verification using mux-signature header with constant-time comparison.
Task completion lacked idempotency checks. Same request could be replayed indefinitely to inflate child point balance.
Added guard that throws if task.completed === true. Concurrent replay requests now blocked.
Cleanup endpoints accessible to any parent. Used redis.keys('*') and wildcard patterns returning full parsed JSON of every matching record including unrelated family data.
Replaced unrestricted scans with getManageableChildUserIdsForUser() returning only authorized child IDs. Removed rawData field from responses.
Respond endpoint allowed deletion or acceptance of message requests not addressed to the authenticated user.
Added recipient verification ensuring only the intended recipient can respond to message requests.
Child accounts could bypass 'Send Messages' parental restriction and message unrelated adults.
Enforced permission checks for child accounts. Messages now blocked unless recipient is parent, co-parent, sibling, or staff member.
Any authenticated user could read another family's child point balance through the parental control endpoints.
Added authorization checks ensuring only parents/co-parents of the specific child can access point balance data.
TURN server credentials endpoint accessible without authentication, exposing ICE server configuration.
Added session authentication requirement for TURN credentials endpoint.
Changing a password or security code (set, change, or remove) did not invalidate existing sessions. An attacker who had previously stolen or obtained a session cookie retained full authenticated access indefinitely after the victim rotated their credentials. The session cookie remained valid for its full 30-day TTL regardless of credential changes.
Added invalidateOtherSessions() to lib/auth/auth.ts. On login, each new session ID is tracked in a per-user Redis set (user:{id}:sessions). After any credential mutation (password change, security code set/change/remove), all sessions for the user except the one performing the change are immediately deleted from Redis. The current session is preserved so the victim is not logged out.
When a co-parent accepted an invite from a second family without first being removed from the first, addCoParent updated the co-parent's parentUserId field but did not remove them from the old family's parent:{oldId}:coparents Redis set. getAllParentsForChild() reads that set to decide who may access a child's data, so the co-parent retained full authorization on the original family's children indefinitely — long after they appeared to have switched families in the UI.
Added an unlinking step to addCoParent: before linking to a new family, the function now checks whether the co-parent's accountType is already 'co-parent' with a different parentUserId, and if so atomically removes them from the old family's coparents set, deletes the old coparent link key, clears the old children from the co-parent's children set, and removes them from child moderator lists. The guard executes before the new link is written.
The user search rate-limit key was derived exclusively from client-supplied IP headers. An authenticated attacker could rotate spoofed IP values to create fresh rate-limit buckets and bypass the per-IP cap, enabling unlimited user enumeration with only a single valid session.
Replaced the client-header-only IP resolution with the same pattern used elsewhere: the platform-provided runtime IP is the authoritative source in production, with client headers only used as development fallback.
The TURN relay configuration endpoint returned the raw long-lived master credentials directly to any authenticated user. These credentials never expire. Any logged-in user could extract them and use the relay service independently of the application, incurring bandwidth costs and enabling abuse.
Replaced static credential passthrough with a server-side call to the relay provider's temporary credential API. Master credentials now stay server-side only. Clients receive short-lived time-bound tokens generated per-request. If the provider API is unreachable, the endpoint falls back to STUN-only rather than exposing static secrets.
The view_profiles parental permission was evaluated exclusively by an advisory client-callable check endpoint. The actual profile data endpoint performed no parental permission check. A child account with view_profiles disabled by their parent could bypass the restriction entirely by calling the profile data endpoint directly, receiving full profile data including bio, avatar, badges, suspension status, and live stream metadata for any user.
Added server-side view_profiles enforcement to the profile data endpoint. After session resolution, child accounts viewing another user's profile are checked against hasPermission() for view_profiles, with a fallback to childHasViewProfileExemption() to allow viewing the child's own profile and sibling profiles. Requests failing both checks receive 403.
Five viewer lifecycle signaling actions had authorization defects. (1) Ban bypass: the ban check was gated on whether a userId was present in the request body, so a banned user could omit the field entirely and rejoin the stream as an anonymous viewer. (2) Identity spoofing: the viewer registration and heartbeat actions accepted a body userId without validating it against the authenticated session, allowing any caller to map their viewer slot to another user's identity. (3) Viewer enumeration: the broadcaster viewer polling action required no authentication, allowing any caller to enumerate all viewer slots and their associated user IDs for any stream. (4) Profile disclosure: the viewer profile resolution action required no authentication, returning usernames and avatars for all active viewers. (5) Viewer eviction: the disconnect action required no authentication and no ownership check, allowing any caller to remove any other viewer from any stream.
All five handlers now derive viewer identity exclusively from the authenticated session. The viewer registration and heartbeat actions ignore any body-supplied user identifier. The ban check runs against the session identity unconditionally. The broadcaster viewer polling action requires auth and verifies the caller is the stream owner. The viewer profile resolution action requires any authenticated session. The disconnect action requires auth and verifies the session user matches the stored viewer mapping before allowing removal.
Three signaling actions were reachable without authentication and without any ownership check. (1) Watch-party sync: any unauthenticated caller could overwrite the co-watch playback state for any stream, desyncing all viewers in an active session. (2) Follower alert injection: any unauthenticated caller could inject fake follower alert entries into any live stream's alert queue, causing fraudulent notifications on the broadcaster's overlay. (3) Alert queue read: any unauthenticated caller could read the pending alert queue for any stream, disclosing follower usernames and timing metadata.
All three handlers now require an authenticated session (401 if absent). The watch-party sync and alert read actions additionally verify that the authenticated user is the stream's broadcaster (403 otherwise). The follower alert action derives the display name from the authenticated session rather than the request body, preventing spoofing by authenticated users.
The login endpoint was exploitable for username enumeration through two independent differentials. (1) Message differential: a valid username with a wrong password would eventually produce a distinct lockout error message after repeated failed attempts, while a nonexistent username always returned the same generic error regardless of attempt count. An attacker could distinguish valid from nonexistent accounts by observing when the error message changed. (2) Timing differential: nonexistent usernames returned immediately after a database lookup miss, while valid usernames with wrong passwords ran a full password hash comparison first. The latency gap was reliably detectable with a single request.
Two fixes applied to the login handler. For timing: a dummy password hash comparison is now performed for every nonexistent-username rejection before returning, equalising response time with wrong-password rejections. For message: the lockout error is now caught and returned as the same generic error string as all other failed attempts, making all failed login responses textually identical regardless of whether the account exists or is locked.
The username-change path in the profile update flow contained badge-preservation logic that ran whenever a user changed their username. The logic checked for the mere existence of an admin account record in the data store and would reactivate an inactive staff badge or create a missing one if any record was found. Because the demotion process sets isActive=false on the account record but does not delete it, a recently demoted user could trigger the badge restoration by simply submitting a username change. This restored the staff badge and all staff-protected capabilities (anonymous mode, stream moderation immunity, etc.).
Fixed the badge-preservation block in the profile update flow to gate on adminAccount.isActive === true, not merely on the existence of the account record. Both the reactivation branch (inactive badge) and the creation branch (missing badge) are now guarded by this check, so a demoted user with an inactive admin account record cannot trigger either path.
Four chat signaling actions accepted identity fields directly from the request body without verifying them against the authenticated session. (1) The send-message action trusted client-supplied user ID, username, and broadcaster flag, allowing any caller to post messages as any other user or claim broadcaster status. (2) The message retrieval action used a body-supplied user ID to decide whether to expose admin-hidden messages, so any caller could supply the broadcaster's ID and see moderation-hidden content. (3) The delete action used a body-supplied user ID for permission and attribution checks, allowing impersonation of the broadcaster or moderator to delete messages. (4) The allow action used a body-supplied user ID to check broadcaster status, so any user could restore admin-hidden messages by supplying the broadcaster's ID.
All four handlers now derive identity exclusively from the server-side session. All body-supplied identity fields are ignored for authorization. The send, delete, and allow actions return 401 if no valid session is present. The message retrieval action remains publicly readable but hidden-message visibility is now gated on the authenticated session matching the broadcaster's stored identity.
Three endpoints that enforce child messaging restrictions built the family allowlist from Redis keys that were never populated by any part of the application: parents:{userId}, coparents:{userId}, and siblings:{userId}. These keys always returned empty sets, so the family-member exception that is supposed to permit restricted children to message their own parents, co-parents, and siblings silently failed. The same defect existed independently in the send-message route, the fetch-messages route, and the check-message-permission advisory route. The advisory endpoint additionally called getCoParents() with the child's own user ID instead of the primary parent's ID, and compared the returned strings against a .id property that does not exist on strings, ensuring the co-parent check always returned false. The net result was that children with send_messages disabled could not message their own family — a stricter restriction than intended — but the converse (children bypassing restrictions to message non-family) did not occur.
Replaced all three incorrect family-lookup blocks with a single call to the existing isFamilyCircleMemberOfChild() library function, which correctly resolves the primary parent, co-parents, and siblings from the canonical Redis key patterns used by the rest of the parental-control system. The unused getSiblings() helper in the advisory route and its stale imports were removed.
The child account creation endpoint did not verify that the authenticated caller was a parent or co-parent account. Any child account could call the child account creation endpoint and successfully invoke createChildAccount() with their own user ID as the parentUserId. Because isParentOrCoParent() determines authority by reading the parent:{id}:children Redis set — which createChildAccount() populates unconditionally — the child became the recognized parent of the new account. The child could then call the permissions endpoints, pass the isParentOrCoParent authorization check, and grant the subordinate account any capability (send_messages, streaming, etc.) regardless of the restrictions placed on the child itself. The child could also use the subordinate account to access features they were personally blocked from.
Added an account-type guard as the first check in the child account creation POST handler: if the caller's accountType is 'child' the request is rejected with 403 before any account creation logic runs. Added the same guard to both the GET and POST handlers of the permissions endpoint as defence-in-depth, so child accounts are blocked from reading or writing child permissions even if a pre-existing unauthorized parent relationship exists in Redis.
The assign-stream-moderator signaling action checked that the caller was either the stream broadcaster or held the assign_moderators permission, but did not prevent a moderator from targeting their own user ID. A moderator who had been granted only assign_moderators (without ban_user, timeout_user, block_chat, or delete_messages) could call the endpoint with targetUserId set to their own ID and request the full permission set, unconditionally overwriting their Redis moderator record with all five permissions. The same gap allowed any delegating moderator to grant permissions they did not themselves hold — for example, a moderator with only assign_moderators could elevate a third party to full moderator status with ban and timeout capabilities the broadcaster never intended to grant.
Added two guards to the assign-stream-moderator handler. First, self-targeting is now explicitly rejected: if targetUserId equals the authenticated caller's ID the request is refused as an invalid target. Second, non-broadcaster callers are now restricted to granting only permissions they currently hold — the requested permissions array is filtered against the caller's own permission set before the moderator record is written. Broadcasters remain unrestricted and may grant any valid permission to any eligible user.
The user online-status endpoint required no authentication. Any unauthenticated caller could poll the online/offline status and last login timestamp of arbitrary users by username or user ID. The response also echoed back the resolved canonical user ID, enabling username-to-ID enumeration.
Added session authentication requirement to the online-status endpoint. Requests without a valid session receive 401. Removed the resolved user ID field from the response body to prevent ID enumeration.
Found a security issue? We appreciate responsible disclosure.
security@streamd.live