diff --git a/Cargo.lock b/Cargo.lock
index 9ffea4a92d..1a63b4f425 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3039,11 +3039,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
 dependencies = [
  "cfg-if 1.0.4",
- "js-sys",
  "libc",
  "r-efi 5.3.0",
  "wasip2",
- "wasm-bindgen",
 ]
 
 [[package]]
@@ -6823,15 +6821,16 @@ dependencies = [
 
 [[package]]
 name = "quinn-proto"
-version = "0.11.14"
+version = "0.11.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
 dependencies = [
  "aws-lc-rs",
  "bytes",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
  "lru-slab",
- "rand 0.9.4",
+ "rand 0.10.1",
+ "rand_pcg",
  "ring",
  "rustc-hash",
  "rustls",
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 1df6045032..254a12d9a5 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -2657,6 +2657,23 @@ impl Db {
         workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await
     }
 
+    /// Disable all of an owner's workflows in a channel (SEC-006, on
+    /// membership loss). Returns the number of workflows disabled.
+    pub async fn disable_workflows_for_owner_in_channel(
+        &self,
+        community_id: CommunityId,
+        channel_id: Uuid,
+        owner_pubkey: &[u8],
+    ) -> Result<u64> {
+        workflow::disable_workflows_for_owner_in_channel(
+            &self.pool,
+            community_id,
+            channel_id,
+            owner_pubkey,
+        )
+        .await
+    }
+
     /// Delete a workflow and all its runs/approvals.
     pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> {
         workflow::delete_workflow(&self.pool, community_id, id).await
diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/workflow.rs
index 9c02f162c9..7a2396c1fd 100644
--- a/crates/buzz-db/src/workflow.rs
+++ b/crates/buzz-db/src/workflow.rs
@@ -707,6 +707,36 @@ pub async fn set_workflow_enabled(
     Ok(())
 }
 
+/// Disable all of `owner_pubkey`'s workflows in a channel (SEC-006).
+///
+/// Called when the owner loses channel membership (kind 9001 removal or kind
+/// 9022 leave) so their workflows stop firing durably — across pods and
+/// restarts — rather than only until the per-fire authority gate happens to
+/// run. Idempotent; returns the number of workflows disabled so the caller
+/// can decide whether a trigger-cache invalidation is needed.
+pub async fn disable_workflows_for_owner_in_channel(
+    pool: &PgPool,
+    community_id: CommunityId,
+    channel_id: Uuid,
+    owner_pubkey: &[u8],
+) -> Result<u64> {
+    let affected = sqlx::query(
+        r#"
+        UPDATE workflows
+        SET enabled = FALSE
+        WHERE community_id = $1 AND channel_id = $2 AND owner_pubkey = $3 AND enabled = TRUE
+        "#,
+    )
+    .bind(community_id.as_uuid())
+    .bind(channel_id)
+    .bind(owner_pubkey)
+    .execute(pool)
+    .await?
+    .rows_affected();
+
+    Ok(affected)
+}
+
 /// Delete a workflow and all its runs/approvals (CASCADE).
 ///
 /// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's
@@ -2272,4 +2302,100 @@ mod tests {
             "B's approval must remain pending after A is granted"
         );
     }
+
+    // -- SEC-006: disable-on-membership-loss primitive -------------------------
+
+    /// `disable_workflows_for_owner_in_channel` must disable exactly the
+    /// departing owner's enabled workflows in that channel — not other owners'
+    /// workflows, not the same owner's workflows in other channels — and be
+    /// idempotent. Disabled workflows must drop out of the trigger-path list.
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn disable_for_owner_scopes_to_owner_and_channel() {
+        let pool = setup_pool().await;
+        let community = make_community(&pool).await;
+
+        let departing = vec![0xd1; 32];
+        let staying = vec![0xd2; 32];
+        ensure_user(&pool, community, &departing)
+            .await
+            .expect("ensure departing");
+        ensure_user(&pool, community, &staying)
+            .await
+            .expect("ensure staying");
+
+        let channel_a = make_channel(&pool, community, &departing).await;
+        let channel_b = make_channel(&pool, community, &departing).await;
+
+        let def = r#"{"trigger":{"on":"message_posted"},"steps":[]}"#;
+        let wf_departing_a = create_workflow(
+            &pool,
+            community,
+            Some(channel_a),
+            &departing,
+            "departing-a",
+            def,
+            &[0u8; 32],
+        )
+        .await
+        .expect("wf departing a");
+        let wf_departing_b = create_workflow(
+            &pool,
+            community,
+            Some(channel_b),
+            &departing,
+            "departing-b",
+            def,
+            &[0u8; 32],
+        )
+        .await
+        .expect("wf departing b");
+        let wf_staying_a = create_workflow(
+            &pool,
+            community,
+            Some(channel_a),
+            &staying,
+            "staying-a",
+            def,
+            &[0u8; 32],
+        )
+        .await
+        .expect("wf staying a");
+
+        let disabled =
+            disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing)
+                .await
+                .expect("disable");
+        assert_eq!(
+            disabled, 1,
+            "exactly the departing owner's channel-A workflow"
+        );
+
+        // Idempotent: second call finds nothing enabled.
+        let again = disable_workflows_for_owner_in_channel(&pool, community, channel_a, &departing)
+            .await
+            .expect("disable again");
+        assert_eq!(again, 0, "second disable must be a no-op");
+
+        let enabled_a = list_enabled_channel_workflows(&pool, community, channel_a)
+            .await
+            .expect("list channel a");
+        let enabled_a_ids: Vec<Uuid> = enabled_a.iter().map(|w| w.id).collect();
+        assert!(
+            !enabled_a_ids.contains(&wf_departing_a),
+            "departing owner's workflow must no longer be trigger-eligible"
+        );
+        assert!(
+            enabled_a_ids.contains(&wf_staying_a),
+            "other owners' workflows in the channel must be untouched"
+        );
+
+        let enabled_b = list_enabled_channel_workflows(&pool, community, channel_b)
+            .await
+            .expect("list channel b");
+        assert!(
+            enabled_b.iter().any(|w| w.id == wf_departing_b),
+            "same owner's workflow in a different channel must be untouched"
+        );
+    }
 }
diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs
index 9cfa878d46..b7d89f40a4 100644
--- a/crates/buzz-relay/src/api/bridge.rs
+++ b/crates/buzz-relay/src/api/bridge.rs
@@ -2012,6 +2012,26 @@ pub async fn workflow_webhook(
     }
     let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok();
 
+    // SEC-006: the webhook secret authenticates the *caller*, but the run
+    // executes with the workflow **owner's** standing authority — so the
+    // secret alone is insufficient. Immediately before run creation, reject
+    // disabled/inactive workflows and recheck the owner's current channel
+    // membership (and role, for exfiltration-capable definitions). Fail
+    // closed with the same generic 404 as the lookups above so a
+    // revoked-owner workflow is indistinguishable from a nonexistent one.
+    if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active {
+        return Err(not_found("workflow not found"));
+    }
+    let Some(wf_channel_id) = workflow.channel_id else {
+        // No channel scope means no channel authority to verify — fail closed.
+        return Err(not_found("workflow not found"));
+    };
+    state
+        .workflow_engine
+        .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def)
+        .await
+        .map_err(|_| not_found("workflow not found"))?;
+
     let run_id = state
         .db
         .create_workflow_run(community_id, id, None, trigger_ctx_json.as_ref())
diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs
index 80e74e1ca0..df5bdd4c3e 100644
--- a/crates/buzz-relay/src/api/git/transport.rs
+++ b/crates/buzz-relay/src/api/git/transport.rs
@@ -63,8 +63,10 @@ const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024;
 /// Validates the `Authorization: Nostr <base64>` header before the request body
 /// is read. Same pattern as `AuthenticatedUpload` in media.rs.
 ///
-/// Authorization model: any authenticated pubkey can clone; push authorization
-/// is handled by the pre-receive hook (calls back to the internal policy endpoint
+/// Authorization model: reads (ref advertisement, upload-pack) require the
+/// caller's *current* active membership in the repo's bound channel — see
+/// [`authorize_git_read`] (SEC-005). Push authorization is additionally
+/// handled by the pre-receive hook (calls back to the internal policy endpoint
 /// which checks channel role + protection rules from kind:30617).
 pub struct GitAuth {
     /// The authenticated user's public key, extracted from the NIP-98 event.
@@ -354,6 +356,114 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp
         .into_response()
 }
 
+/// SEC-005: authorize a repository *read* (ref advertisement, upload-pack).
+///
+/// The authorization invariant is the authenticated git caller's **current
+/// active membership in the repo's bound channel**. NIP-98 alone only proves
+/// key possession — without this gate any authenticated pubkey (including a
+/// member removed from the channel) can clone channel-bound repositories.
+///
+/// Resolution follows the current authoritative announcement, the same
+/// mapping the push policy endpoint uses:
+/// 1. current live kind:30617 by `(community, owner pubkey from the URL,
+///    d = canonical repo name)` — soft-deleted/replaced announcements do not
+///    resolve;
+/// 2. its `buzz-channel` tag → channel UUID;
+/// 3. [`buzz_db::Db::get_member_role`] for the caller — a read is allowed
+///    only on `Ok(Some(role))` with a role the relay recognizes.
+///
+/// Fail-closed: missing/deleted announcement, invalid owner, missing or
+/// malformed `buzz-channel` binding, non-member, unknown role, and every DB
+/// error all deny. There is deliberately **no repo-owner bypass**: an owner
+/// removed from the bound channel loses read access, which is the exact
+/// exploit shape this gate closes. Every denial is the same generic 404 as a
+/// nonexistent repo so membership cannot be probed through the git endpoints.
+async fn authorize_git_read(
+    db: &buzz_db::Db,
+    community: buzz_core::CommunityId,
+    caller: &nostr::PublicKey,
+    owner_hex: &str,
+    repo_name: &str,
+) -> Result<(), Response> {
+    fn denied() -> Response {
+        (StatusCode::NOT_FOUND, "repository not found").into_response()
+    }
+
+    let Ok(owner_bytes) = hex::decode(owner_hex) else {
+        return Err(denied());
+    };
+    if owner_bytes.len() != 32 {
+        return Err(denied());
+    }
+
+    let query = buzz_db::EventQuery {
+        kinds: Some(vec![30617]),
+        pubkey: Some(owner_bytes),
+        d_tag: Some(repo_name.to_string()),
+        global_only: true,
+        limit: Some(1),
+        ..buzz_db::EventQuery::for_community(community)
+    };
+    let repo_event = match db.query_events(&query).await {
+        Ok(mut events) => match events.pop() {
+            Some(event) => event,
+            None => return Err(denied()),
+        },
+        Err(e) => {
+            error!(repo = %repo_name, error = %e, "git read gate: 30617 lookup failed (deny)");
+            return Err(denied());
+        }
+    };
+
+    let Some(channel_id) = repo_bound_channel_id(&repo_event.event) else {
+        warn!(repo = %repo_name, "git read gate: missing/malformed buzz-channel binding (deny)");
+        return Err(denied());
+    };
+
+    match db
+        .get_member_role(community, channel_id, &caller.to_bytes())
+        .await
+    {
+        Ok(role) if read_role_allows(role.as_deref()) => Ok(()),
+        Ok(_) => Err(denied()),
+        Err(e) => {
+            error!(repo = %repo_name, error = %e, "git read gate: role lookup failed (deny)");
+            Err(denied())
+        }
+    }
+}
+
+/// Extract the `buzz-channel` UUID from a kind:30617 announcement.
+///
+/// First-tag semantics, matching the push policy endpoint: only the *first*
+/// `buzz-channel` tag is considered, and it must carry a valid UUID. A
+/// malformed first binding denies even if a later duplicate tag is valid —
+/// an ambiguous announcement must fail closed, not silently resolve to
+/// whichever duplicate happens to parse.
+fn repo_bound_channel_id(event: &nostr::Event) -> Option<uuid::Uuid> {
+    let first = event
+        .tags
+        .iter()
+        .find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))?;
+    first
+        .as_slice()
+        .get(1)
+        .and_then(|v| uuid::Uuid::parse_str(v).ok())
+}
+
+/// Pure decision for [`authorize_git_read`]: a read requires a current
+/// active membership row whose role the relay recognizes.
+///
+/// `None` = not an active member (removed, left, never joined) ⇒ deny.
+/// An unrecognized role string ⇒ deny (fail-closed, same as the push
+/// policy endpoint).
+fn read_role_allows(role: Option<&str>) -> bool {
+    match role {
+        Some(r) => r.parse::<buzz_core::channel::MemberRole>().is_ok(),
+        None => false,
+    }
+}
+
 #[derive(Deserialize)]
 /// Query parameters for the `info/refs` endpoint.
 pub struct InfoRefsQuery {
@@ -547,7 +657,19 @@ pub async fn info_refs(
         "git-upload-pack" | "git-receive-pack" => &query.service,
         _ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()),
     };
-    let _repo_name = validate_repo_id(&params.owner, &params.repo)?;
+    let repo_name = validate_repo_id(&params.owner, &params.repo)?;
+
+    // SEC-005: channel-membership gate before any manifest load, hydration,
+    // or subprocess work. Both services — the receive-pack advertisement
+    // leaks the ref list just like the upload-pack one.
+    authorize_git_read(
+        &state.db,
+        auth.tenant.community(),
+        &auth.pubkey,
+        &params.owner,
+        repo_name,
+    )
+    .await?;
 
     // Track C fast path: only for clone advertisement. The receive-pack
     // advertisement carries a different capability set (report-status,
@@ -790,7 +912,21 @@ pub async fn upload_pack(
     AxumPath(params): AxumPath<GitRepoParams>,
     body: Body,
 ) -> Result<Response, Response> {
-    let _ = validate_repo_id(&params.owner, &params.repo)?;
+    let repo_name = validate_repo_id(&params.owner, &params.repo)?;
+
+    // SEC-005: the reused NIP-98 token means the GET advertisement's
+    // authorization cannot stand in for POST-time membership — gate this
+    // door independently, before body decode work is driven or hydration
+    // starts.
+    authorize_git_read(
+        &state.db,
+        auth.tenant.community(),
+        &auth.pubkey,
+        &params.owner,
+        repo_name,
+    )
+    .await?;
+
     let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES);
     let permit = acquire_git_permit(&state, "upload_pack")?;
 
@@ -2286,3 +2422,335 @@ mod track_c_tests {
         assert!(caps.contains("object-format=sha256"));
     }
 }
+
+#[cfg(test)]
+mod sec005_read_gate_tests {
+    use super::*;
+    use nostr::{EventBuilder, Keys, Kind, Tag};
+
+    // ── Pure decision helpers ────────────────────────────────────────────
+
+    #[test]
+    fn read_role_allows_every_recognized_role() {
+        for role in ["owner", "admin", "member", "guest", "bot"] {
+            assert!(read_role_allows(Some(role)), "role {role:?} must allow");
+        }
+    }
+
+    #[test]
+    fn read_role_denies_non_members_and_unknown_roles() {
+        assert!(!read_role_allows(None), "no membership row must deny");
+        assert!(
+            !read_role_allows(Some("superuser")),
+            "unrecognized role must deny (fail-closed)"
+        );
+        assert!(!read_role_allows(Some("")), "empty role must deny");
+    }
+
+    fn announcement(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
+        EventBuilder::new(Kind::Custom(30617), "")
+            .tags(tags)
+            .sign_with_keys(keys)
+            .expect("sign 30617")
+    }
+
+    #[test]
+    fn repo_bound_channel_id_extracts_valid_uuid() {
+        let keys = Keys::generate();
+        let ch = uuid::Uuid::new_v4();
+        let event = announcement(
+            &keys,
+            vec![
+                Tag::parse(["d", "r"]).unwrap(),
+                Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
+            ],
+        );
+        assert_eq!(repo_bound_channel_id(&event), Some(ch));
+    }
+
+    #[test]
+    fn repo_bound_channel_id_rejects_absent_and_malformed_bindings() {
+        let keys = Keys::generate();
+        let absent = announcement(&keys, vec![Tag::parse(["d", "r"]).unwrap()]);
+        assert_eq!(repo_bound_channel_id(&absent), None);
+
+        let malformed = announcement(
+            &keys,
+            vec![
+                Tag::parse(["d", "r"]).unwrap(),
+                Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
+            ],
+        );
+        assert_eq!(repo_bound_channel_id(&malformed), None);
+
+        let empty = announcement(
+            &keys,
+            vec![
+                Tag::parse(["d", "r"]).unwrap(),
+                Tag::parse(["buzz-channel"]).unwrap(),
+            ],
+        );
+        assert_eq!(repo_bound_channel_id(&empty), None);
+    }
+
+    #[test]
+    fn repo_bound_channel_id_fails_closed_on_ambiguous_duplicate_bindings() {
+        // First-tag semantics: a malformed first binding must deny even when
+        // a later duplicate tag is valid. An ambiguous announcement must not
+        // silently resolve to whichever duplicate happens to parse.
+        let keys = Keys::generate();
+        let ch = uuid::Uuid::new_v4();
+        let malformed_first = announcement(
+            &keys,
+            vec![
+                Tag::parse(["d", "r"]).unwrap(),
+                Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(),
+                Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
+            ],
+        );
+        assert_eq!(repo_bound_channel_id(&malformed_first), None);
+
+        // And the mirror image: a valid first binding wins, matching the
+        // push policy endpoint's first-tag resolution.
+        let other = uuid::Uuid::new_v4();
+        let valid_first = announcement(
+            &keys,
+            vec![
+                Tag::parse(["d", "r"]).unwrap(),
+                Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(),
+                Tag::parse(["buzz-channel", &other.to_string()]).unwrap(),
+            ],
+        );
+        assert_eq!(repo_bound_channel_id(&valid_first), Some(ch));
+    }
+
+    // ── authorize_git_read matrix (requires Postgres) ────────────────────
+
+    const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
+
+    async fn setup_db() -> buzz_db::Db {
+        let url = std::env::var("BUZZ_TEST_DATABASE_URL")
+            .or_else(|_| std::env::var("DATABASE_URL"))
+            .unwrap_or_else(|_| TEST_DB_URL.to_string());
+        let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB");
+        buzz_db::Db::from_pool(pool)
+    }
+
+    /// How the fixture's kind:30617 binds (or fails to bind) a channel.
+    enum Binding {
+        /// `buzz-channel` tag carrying the fixture channel's UUID.
+        Channel,
+        /// No `buzz-channel` tag at all.
+        Missing,
+        /// `buzz-channel` tag whose value is not a UUID.
+        Malformed,
+    }
+
+    struct RepoFixture {
+        db: buzz_db::Db,
+        community: buzz_core::CommunityId,
+        channel: uuid::Uuid,
+        owner_keys: Keys,
+        owner_hex: String,
+        member_keys: Keys,
+        repo: String,
+    }
+
+    /// Community + channel + one plain member + a kind:30617 announcement.
+    /// The repo owner is a *different* key that is not a channel member —
+    /// deliberately, to pin "no repo-owner bypass".
+    async fn setup_repo(binding: Binding) -> RepoFixture {
+        let db = setup_db().await;
+        let host = format!("sec005-{}.example", uuid::Uuid::new_v4().simple());
+        let community = db
+            .ensure_configured_community(&host)
+            .await
+            .expect("community")
+            .id;
+
+        let owner_keys = Keys::generate();
+        let member_keys = Keys::generate();
+        let creator = Keys::generate(); // channel creator, distinct from repo owner
+        let creator_pk = creator.public_key().to_bytes().to_vec();
+        let member_pk = member_keys.public_key().to_bytes().to_vec();
+        db.ensure_user(community, &creator_pk).await.expect("user");
+        db.ensure_user(community, &member_pk).await.expect("user");
+
+        let channel = uuid::Uuid::new_v4();
+        db.create_channel_with_id(
+            community,
+            channel,
+            &format!("ch-{}", channel.simple()),
+            buzz_db::channel::ChannelType::Stream,
+            buzz_db::channel::ChannelVisibility::Open,
+            None,
+            &creator_pk,
+            None,
+        )
+        .await
+        .expect("channel");
+        db.add_member(
+            community,
+            channel,
+            &member_pk,
+            buzz_core::channel::MemberRole::Member,
+            Some(&creator_pk),
+        )
+        .await
+        .expect("member");
+
+        let repo = format!("repo-{}", uuid::Uuid::new_v4().simple());
+        let mut tags = vec![Tag::parse(["d", &repo]).unwrap()];
+        match binding {
+            Binding::Channel => {
+                tags.push(Tag::parse(["buzz-channel", &channel.to_string()]).unwrap());
+            }
+            Binding::Missing => {}
+            Binding::Malformed => {
+                tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap());
+            }
+        }
+        let event = announcement(&owner_keys, tags);
+        db.insert_event(community, &event, None)
+            .await
+            .expect("30617");
+
+        let owner_hex = owner_keys.public_key().to_hex();
+        RepoFixture {
+            db,
+            community,
+            channel,
+            owner_keys,
+            owner_hex,
+            member_keys,
+            repo,
+        }
+    }
+
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn read_gate_allows_current_member_denies_removed_and_owner_bypass() {
+        let f = setup_repo(Binding::Channel).await;
+
+        // Current member: allowed.
+        let member = f.member_keys.public_key();
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
+                .await
+                .is_ok(),
+            "current member must be allowed to read"
+        );
+
+        // Never-a-member caller: denied.
+        let stranger = Keys::generate().public_key();
+        assert!(
+            authorize_git_read(&f.db, f.community, &stranger, &f.owner_hex, &f.repo)
+                .await
+                .is_err(),
+            "non-member must be denied"
+        );
+
+        // Member removed → denied. THE finding-005 exploit shape.
+        let member_pk = member.to_bytes().to_vec();
+        f.db.remove_member(f.community, f.channel, &member_pk, &member_pk)
+            .await
+            .expect("self-remove");
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
+                .await
+                .is_err(),
+            "removed member must be denied"
+        );
+
+        // No repo-owner bypass: the announcement author is not a channel
+        // member and must be denied too.
+        let owner = f.owner_keys.public_key();
+        assert!(
+            authorize_git_read(&f.db, f.community, &owner, &f.owner_hex, &f.repo)
+                .await
+                .is_err(),
+            "repo owner outside the channel must be denied (no owner bypass)"
+        );
+    }
+
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() {
+        // Missing buzz-channel tag → deny even for a channel member.
+        let f = setup_repo(Binding::Missing).await;
+        let member = f.member_keys.public_key();
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
+                .await
+                .is_err(),
+            "announcement without buzz-channel binding must deny"
+        );
+
+        // Malformed buzz-channel tag → deny.
+        let g = setup_repo(Binding::Malformed).await;
+        let member_g = g.member_keys.public_key();
+        assert!(
+            authorize_git_read(&g.db, g.community, &member_g, &g.owner_hex, &g.repo)
+                .await
+                .is_err(),
+            "announcement with malformed buzz-channel binding must deny"
+        );
+
+        // Nonexistent announcement → deny.
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo")
+                .await
+                .is_err(),
+            "nonexistent repo must deny"
+        );
+
+        // Owner-mismatch: URL owner differs from announcement author → deny.
+        let impostor_hex = Keys::generate().public_key().to_hex();
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &impostor_hex, &f.repo)
+                .await
+                .is_err(),
+            "URL owner that never announced this repo must deny"
+        );
+
+        // Invalid owner hex in URL → deny (never panics).
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, "zz-not-hex", &f.repo)
+                .await
+                .is_err(),
+            "malformed owner hex must deny"
+        );
+    }
+
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn read_gate_follows_current_announcement_not_stale_registry() {
+        // Max's registry/pointer concern: a soft-deleted 30617 can leave the
+        // `git_repo_names` reservation and the manifest pointer alive. Reads
+        // must follow the current authoritative announcement — once it's
+        // deleted, the gate denies even a current channel member.
+        let f = setup_repo(Binding::Channel).await;
+
+        let member = f.member_keys.public_key();
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
+                .await
+                .is_ok(),
+            "precondition: member allowed while announcement is live"
+        );
+
+        let owner_pk = f.owner_keys.public_key().to_bytes().to_vec();
+        let deleted =
+            f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo)
+                .await
+                .expect("soft delete 30617");
+        assert!(deleted, "precondition: a live announcement row was deleted");
+
+        assert!(
+            authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo)
+                .await
+                .is_err(),
+            "deleted announcement must deny reads even for channel members"
+        );
+    }
+}
diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs
index bd3eff4de9..2d82736807 100644
--- a/crates/buzz-relay/src/handlers/command_executor.rs
+++ b/crates/buzz-relay/src/handlers/command_executor.rs
@@ -685,6 +685,24 @@ async fn handle_workflow_def(
         .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?;
     let workflow_name = extract_tag(event, "name").unwrap_or_else(|| def.name.clone());
 
+    // SEC-006: definitions with exfiltration-capable actions (call_webhook)
+    // require elevated channel authority to save — plain membership is not
+    // enough, because the workflow will forward channel content outward with
+    // the owner's standing authority. Fail-closed on lookup errors.
+    if def.requires_elevated_authority() {
+        let role = state
+            .db
+            .get_member_role(tenant.community(), channel_id, &self_bytes)
+            .await
+            .map_err(|e| IngestError::Internal(format!("error: role check: {e}")))?;
+        if !matches!(role.as_deref(), Some("owner") | Some("admin")) {
+            return Err(IngestError::Rejected(
+                "forbidden: workflows with call_webhook actions require the owner or admin role"
+                    .into(),
+            ));
+        }
+    }
+
     let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str)
         .map_err(|e| IngestError::Internal(format!("error: json parse of definition: {e}")))?;
 
@@ -843,6 +861,31 @@ async fn handle_workflow_trigger(
         ));
     }
 
+    // SEC-006: manual triggers must honor the workflow's lifecycle state and
+    // recheck the owner's *current* channel authority before creating a run.
+    // Without this, a disabled workflow — including one disabled because its
+    // owner was removed from the channel — could still be fired by the owner.
+    if !workflow.enabled || workflow.status != buzz_db::workflow::WorkflowStatus::Active {
+        return Err(IngestError::Rejected(
+            "forbidden: workflow is disabled or inactive".into(),
+        ));
+    }
+    let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone())
+        .map_err(|e| IngestError::Internal(format!("error: corrupt workflow definition: {e}")))?;
+    let Some(wf_channel_id) = workflow.channel_id else {
+        // No channel scope means no channel authority to verify — fail closed.
+        return Err(IngestError::Rejected(
+            "forbidden: workflow has no channel scope".into(),
+        ));
+    };
+    state
+        .workflow_engine
+        .check_owner_authority(community_id, wf_channel_id, &workflow.owner_pubkey, &def)
+        .await
+        .map_err(|_| {
+            IngestError::Rejected("forbidden: not authorized to trigger this workflow".into())
+        })?;
+
     // Persist the command event under the workflow channel even though the
     // trigger event itself only carries the workflow UUID. Storing channel
     // triggers as global events leaks workflow IDs to unrelated relay members.
diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs
index 6ff1315acc..65d04ef0ba 100644
--- a/crates/buzz-relay/src/handlers/side_effects.rs
+++ b/crates/buzz-relay/src/handlers/side_effects.rs
@@ -51,6 +51,52 @@ async fn evict_live_channel_subscriptions(
     }
 }
 
+/// Durably disable a departing member's workflows in the channel (SEC-006).
+///
+/// A workflow runs with its owner's standing authority; once the owner is no
+/// longer a member (removed via kind 9001 or left via kind 9022) their
+/// workflows must stop firing on every path — event triggers, the scheduler,
+/// manual triggers, and the webhook endpoint all honor `enabled = FALSE`.
+/// The per-fire authority gate in `buzz-workflow` is the fail-closed backstop;
+/// this makes the revocation durable and immediately visible.
+///
+/// Failures are logged, not propagated: membership removal has already been
+/// committed, and the per-fire gate still denies a removed owner even if this
+/// disable write is lost.
+async fn disable_departed_member_workflows(
+    tenant: &TenantContext,
+    state: &Arc<AppState>,
+    channel_id: Uuid,
+    target_pubkey: &[u8],
+) {
+    match state
+        .db
+        .disable_workflows_for_owner_in_channel(tenant.community(), channel_id, target_pubkey)
+        .await
+    {
+        Ok(0) => {}
+        Ok(n) => {
+            tracing::info!(
+                channel = %channel_id,
+                owner = %hex::encode(target_pubkey),
+                disabled = n,
+                "Disabled departed member's workflows"
+            );
+            state
+                .workflow_engine
+                .invalidate_channel_workflows(tenant.community(), channel_id);
+        }
+        Err(e) => {
+            warn!(
+                channel = %channel_id,
+                owner = %hex::encode(target_pubkey),
+                error = %e,
+                "Failed to disable departed member's workflows — per-fire authority gate still denies"
+            );
+        }
+    }
+}
+
 /// Close every live channel-scoped subscription on `conn_id`, removing them from
 /// the connection's local map and sending `CLOSED restricted` for each.
 async fn evict_conn_channel_subscriptions(
@@ -1341,6 +1387,7 @@ async fn handle_remove_user(
         .await?;
     state.invalidate_membership(tenant, channel_id, &target_pubkey);
     evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await;
+    disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await;
 
     let actor_hex = hex::encode(&actor_bytes);
     let target_hex = hex::encode(&target_pubkey);
@@ -1987,6 +2034,7 @@ async fn handle_leave_request(
         .await?;
     state.invalidate_membership(tenant, channel_id, &actor_bytes);
     evict_live_channel_subscriptions(tenant, state, channel_id, &actor_bytes).await;
+    disable_departed_member_workflows(tenant, state, channel_id, &actor_bytes).await;
 
     let actor_hex = hex::encode(&actor_bytes);
     emit_system_message(
diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs
index 34e3d5311f..292f8dd027 100644
--- a/crates/buzz-workflow/src/error.rs
+++ b/crates/buzz-workflow/src/error.rs
@@ -54,6 +54,12 @@ pub enum WorkflowError {
     #[error("database error: {0}")]
     Database(String),
 
+    /// The workflow's owner is not currently authorized to run it (removed
+    /// from the channel, insufficient role for the definition's actions, or
+    /// the authority lookup failed — all deny, fail-closed).
+    #[error("unauthorized: {0}")]
+    Unauthorized(String),
+
     /// The action is defined but not yet implemented.
     #[error("action not implemented: {0}")]
     NotImplemented(String),
diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs
index 93581225ee..7aaa3d1702 100644
--- a/crates/buzz-workflow/src/lib.rs
+++ b/crates/buzz-workflow/src/lib.rs
@@ -132,6 +132,44 @@ impl WorkflowEngine {
         self.workflow_cache.invalidate(&(community_id, channel_id));
     }
 
+    /// Fail-closed pre-run authority gate (SEC-006).
+    ///
+    /// A workflow executes with its **owner's** standing authority long after
+    /// the definition was saved, so every run-creation door must recheck the
+    /// owner's *current* channel authority immediately before creating a run:
+    ///
+    /// - the owner must still be an active member of the workflow's channel;
+    /// - if the definition contains an exfiltration-capable action
+    ///   (`call_webhook`), the owner must currently hold the `owner` or
+    ///   `admin` role.
+    ///
+    /// Any lookup error denies (fail-closed): a removed owner must never keep
+    /// exfiltration authority because a membership read happened to fail.
+    pub async fn check_owner_authority(
+        &self,
+        community_id: CommunityId,
+        channel_id: Uuid,
+        owner_pubkey: &[u8],
+        def: &WorkflowDef,
+    ) -> Result<(), WorkflowError> {
+        let role = self
+            .db
+            .get_member_role(community_id, channel_id, owner_pubkey)
+            .await
+            .map_err(|e| {
+                WorkflowError::Unauthorized(format!(
+                    "owner authority lookup failed (fail-closed): {e}"
+                ))
+            })?;
+        if owner_authority_allows(role.as_deref(), def.requires_elevated_authority()) {
+            Ok(())
+        } else {
+            Err(WorkflowError::Unauthorized(
+                "workflow owner lacks current channel authority".into(),
+            ))
+        }
+    }
+
     /// Set the action sink. Called once after `AppState` construction.
     ///
     /// # Panics
@@ -340,6 +378,23 @@ impl WorkflowEngine {
                 continue;
             }
 
+            // SEC-006: recheck the owner's *current* channel authority
+            // immediately before run creation. The cached workflow list can be
+            // up to 10s stale, and disable-on-removal can race a concurrent
+            // event — this per-fire gate is the authoritative, fail-closed
+            // check that a removed (or under-privileged, for exfiltration
+            // definitions) owner cannot cause a run.
+            if let Err(e) = self
+                .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
+                .await
+            {
+                tracing::warn!(
+                    workflow_id = %workflow.id,
+                    "Skipping workflow — owner authority check failed: {e}"
+                );
+                continue;
+            }
+
             let trigger_event_id_bytes = event.event.id.as_bytes().to_vec();
             let run_id = match self
                 .db
@@ -537,6 +592,22 @@ impl WorkflowEngine {
                     _ => continue, // Non-schedule triggers handled by on_event()
                 };
 
+                // SEC-006: recheck the owner's current channel authority
+                // BEFORE the durable claim. Placing the gate after the claim
+                // would let a revoked owner's workflow consume the
+                // at-most-once fire slot (claims are never re-fired), turning
+                // revocation into a denial-of-fire for a later re-enable.
+                if let Err(e) = self
+                    .check_owner_authority(community_id, channel_id, &workflow.owner_pubkey, &def)
+                    .await
+                {
+                    tracing::warn!(
+                        workflow_id = %workflow.id,
+                        "Cron tick: skipping workflow — owner authority check failed: {e}"
+                    );
+                    continue;
+                }
+
                 // Durable at-most-once claim — the cross-pod fire boundary.
                 // The loser receives `None` and skips BEFORE any run creation or
                 // side effect. `community_id` is the workflow row's own
@@ -951,6 +1022,25 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge
     }
 }
 
+/// Pure authority decision for [`WorkflowEngine::check_owner_authority`].
+///
+/// `role` is the owner's *current* active role in the workflow's channel
+/// (`None` = not an active member — removed, left, or never joined).
+/// `needs_elevated` is true when the definition contains an
+/// exfiltration-capable action (see `WorkflowDef::requires_elevated_authority`).
+///
+/// Rules:
+/// - not a member ⇒ deny, always;
+/// - member ⇒ allowed for ordinary definitions;
+/// - elevated definitions ⇒ only `owner` / `admin` roles.
+fn owner_authority_allows(role: Option<&str>, needs_elevated: bool) -> bool {
+    match role {
+        None => false,
+        Some(r) if needs_elevated => matches!(r, "owner" | "admin"),
+        Some(_) => true,
+    }
+}
+
 /// Returns `true` if the trigger type matches the given event kind.
 fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
     use buzz_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
@@ -1561,4 +1651,244 @@ steps:
         // Should pick the LAST e tag (direct target), not the first (thread root)
         assert_eq!(ctx.message_id, direct_target_id.to_hex());
     }
+
+    // -- SEC-006: owner authority decision --------------------------------
+
+    #[test]
+    fn owner_authority_denies_non_members_always() {
+        assert!(!owner_authority_allows(None, false));
+        assert!(!owner_authority_allows(None, true));
+    }
+
+    #[test]
+    fn owner_authority_allows_any_member_for_ordinary_definitions() {
+        assert!(owner_authority_allows(Some("member"), false));
+        assert!(owner_authority_allows(Some("admin"), false));
+        assert!(owner_authority_allows(Some("owner"), false));
+    }
+
+    #[test]
+    fn owner_authority_requires_elevated_role_for_exfiltration_definitions() {
+        assert!(!owner_authority_allows(Some("member"), true));
+        assert!(owner_authority_allows(Some("admin"), true));
+        assert!(owner_authority_allows(Some("owner"), true));
+    }
+
+    #[test]
+    fn requires_elevated_authority_detects_call_webhook() {
+        let (plain, _) = WorkflowEngine::parse_yaml(concat!(
+            "name: plain\n",
+            "trigger:\n  on: message_posted\n",
+            "steps:\n  - id: s1\n    action: send_message\n    text: hi\n",
+        ))
+        .expect("parse plain");
+        assert!(!plain.requires_elevated_authority());
+
+        let (hook, _) = WorkflowEngine::parse_yaml(concat!(
+            "name: hook\n",
+            "trigger:\n  on: message_posted\n",
+            "steps:\n  - id: s1\n    action: send_message\n    text: hi\n",
+            "  - id: s2\n    action: call_webhook\n    url: https://example.com/x\n",
+        ))
+        .expect("parse hook");
+        assert!(hook.requires_elevated_authority());
+    }
+
+    // -- SEC-006: event-path regression (requires Postgres) ----------------
+
+    async fn setup_db() -> buzz_db::Db {
+        let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
+            .or_else(|_| std::env::var("DATABASE_URL"))
+            .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned());
+        buzz_db::Db::new(&buzz_db::DbConfig {
+            database_url,
+            ..Default::default()
+        })
+        .await
+        .expect("connect test DB")
+    }
+
+    /// Create a community, a channel owned by `creator`, and add `member` as a
+    /// plain member. Returns `(community, channel)`.
+    async fn setup_channel(db: &buzz_db::Db, creator: &[u8], member: &[u8]) -> (CommunityId, Uuid) {
+        let host = format!("sec006-{}.example", Uuid::new_v4().simple());
+        let community = match db
+            .create_community_with_owner(&host, &hex::encode(creator))
+            .await
+            .expect("create community")
+        {
+            buzz_db::CreateCommunityWithOwnerResult::Created(rec) => rec.id,
+            other => panic!("unexpected community create result: {other:?}"),
+        };
+        db.ensure_user(community, creator)
+            .await
+            .expect("creator user");
+        db.ensure_user(community, member)
+            .await
+            .expect("member user");
+        let channel_id = Uuid::new_v4();
+        db.create_channel_with_id(
+            community,
+            channel_id,
+            &format!("ch-{}", channel_id.simple()),
+            buzz_db::channel::ChannelType::Stream,
+            buzz_db::channel::ChannelVisibility::Open,
+            None,
+            creator,
+            None,
+        )
+        .await
+        .expect("create channel");
+        db.add_member(
+            community,
+            channel_id,
+            member,
+            buzz_db::channel::MemberRole::Member,
+            Some(creator),
+        )
+        .await
+        .expect("add member");
+        (community, channel_id)
+    }
+
+    fn message_event(channel_id: Uuid) -> buzz_core::StoredEvent {
+        let keys = nostr::Keys::generate();
+        let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "hello")
+            .sign_with_keys(&keys)
+            .expect("sign");
+        buzz_core::StoredEvent::new(event, Some(channel_id))
+    }
+
+    /// The event path must stop creating runs the moment the workflow's owner
+    /// loses channel membership — even while the workflow row is still
+    /// `enabled` (the disable-on-removal side effect is a separate, relay-side
+    /// write; this gate must hold on its own).
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn on_event_denies_run_after_owner_removed() {
+        let db = setup_db().await;
+        let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
+        let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
+        let (community, channel_id) = setup_channel(&db, &creator, &member).await;
+
+        let def_json = serde_json::json!({
+            "name": "sec006-event",
+            "trigger": {"on": "message_posted"},
+            "steps": [{"id": "s1", "action": "send_message", "text": "hi"}],
+            "enabled": true,
+        })
+        .to_string();
+        let workflow_id = db
+            .create_workflow(
+                community,
+                Some(channel_id),
+                &member,
+                "sec006-event",
+                &def_json,
+                &[0u8; 32],
+            )
+            .await
+            .expect("create workflow");
+
+        let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
+
+        // Owner is an active member: the event fires the workflow.
+        engine
+            .on_event(community, &message_event(channel_id))
+            .await
+            .expect("on_event while member");
+        let runs = db
+            .list_workflow_runs(community, workflow_id, 10)
+            .await
+            .expect("list runs");
+        assert_eq!(runs.len(), 1, "member owner's workflow must fire");
+
+        // Remove the owner (actor = channel creator, an owner-role member).
+        db.remove_member(community, channel_id, &member, &creator)
+            .await
+            .expect("remove member");
+
+        // Workflow row is still enabled — only the authority gate stands.
+        engine
+            .on_event(community, &message_event(channel_id))
+            .await
+            .expect("on_event after removal");
+        let runs = db
+            .list_workflow_runs(community, workflow_id, 10)
+            .await
+            .expect("list runs after removal");
+        assert_eq!(
+            runs.len(),
+            1,
+            "no new run may be created after the owner lost membership"
+        );
+    }
+
+    /// Exfiltration-capable definitions (call_webhook) require the owner to
+    /// currently hold an elevated role — a plain member's workflow must not
+    /// fire even though the owner is still an active channel member.
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn on_event_denies_webhook_definition_for_plain_member_owner() {
+        let db = setup_db().await;
+        let creator = nostr::Keys::generate().public_key().to_bytes().to_vec();
+        let member = nostr::Keys::generate().public_key().to_bytes().to_vec();
+        let (community, channel_id) = setup_channel(&db, &creator, &member).await;
+
+        let def_json = serde_json::json!({
+            "name": "sec006-hook",
+            "trigger": {"on": "message_posted"},
+            "steps": [{"id": "s1", "action": "call_webhook", "url": "https://example.com/x"}],
+            "enabled": true,
+        })
+        .to_string();
+
+        // Same definition, two owners: plain member vs channel owner.
+        let wf_member = db
+            .create_workflow(
+                community,
+                Some(channel_id),
+                &member,
+                "hook-member",
+                &def_json,
+                &[0u8; 32],
+            )
+            .await
+            .expect("create member workflow");
+        let wf_owner = db
+            .create_workflow(
+                community,
+                Some(channel_id),
+                &creator,
+                "hook-owner",
+                &def_json,
+                &[1u8; 32],
+            )
+            .await
+            .expect("create owner workflow");
+
+        let engine = Arc::new(WorkflowEngine::new(db.clone(), WorkflowConfig::default()));
+        engine
+            .on_event(community, &message_event(channel_id))
+            .await
+            .expect("on_event");
+
+        let member_runs = db
+            .list_workflow_runs(community, wf_member, 10)
+            .await
+            .expect("member runs");
+        assert!(
+            member_runs.is_empty(),
+            "plain member's call_webhook workflow must not fire"
+        );
+        let owner_runs = db
+            .list_workflow_runs(community, wf_owner, 10)
+            .await
+            .expect("owner runs");
+        assert_eq!(
+            owner_runs.len(),
+            1,
+            "channel owner's call_webhook workflow fires"
+        );
+    }
 }
diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs
index 34e8bb1960..9bc79aa48b 100644
--- a/crates/buzz-workflow/src/schema.rs
+++ b/crates/buzz-workflow/src/schema.rs
@@ -147,6 +147,19 @@ pub enum ActionDef {
 }
 
 impl WorkflowDef {
+    /// True when any step performs an action that can exfiltrate channel data
+    /// to an arbitrary external destination (`call_webhook`).
+    ///
+    /// Definitions with such steps require elevated (owner/admin) channel
+    /// authority both to save and to run — plain membership is not enough,
+    /// because a workflow forwards channel content with the *owner's* standing
+    /// authority long after the save (SEC-006).
+    pub fn requires_elevated_authority(&self) -> bool {
+        self.steps
+            .iter()
+            .any(|s| matches!(s.action, ActionDef::CallWebhook { .. }))
+    }
+
     /// Validate the workflow definition. Returns `Err` with a descriptive message
     /// if any invariant is violated.
     pub fn validate(&self) -> Result<(), WorkflowError> {
diff --git a/deploy/charts/buzz/templates/NOTES.txt b/deploy/charts/buzz/templates/NOTES.txt
index d65c1f837b..b409f4d942 100644
--- a/deploy/charts/buzz/templates/NOTES.txt
+++ b/deploy/charts/buzz/templates/NOTES.txt
@@ -64,7 +64,8 @@
 {{- end }}
 {{- if not .Values.relay.requireMediaGetAuth }}
   ⚠ relay.requireMediaGetAuth=false — media GET/HEAD reads are not auth-gated.
-    Keep this false only during staged client rollout.
+    Anyone who learns a media URL/hash can fetch private attachments. Only
+    use for local development or fully public communities.
 {{- end }}
 {{- if not .Values.migrate.autoMigrate }}
   ⚠ migrate.autoMigrate=false — relay startup will NOT run sqlx migrations.
diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml
index 6313fd5958..c50a960d26 100644
--- a/deploy/charts/buzz/tests/render_test.yaml
+++ b/deploy/charts/buzz/tests/render_test.yaml
@@ -36,6 +36,34 @@ tests:
             name: BUZZ_HUDDLE_AUDIO_AVAILABLE
             value: "true"
         template: templates/deployment.yaml
+      # Security default: media GET/HEAD reads must be auth-gated out of the
+      # box. A private attachment must never be publicly readable by URL/hash
+      # in an unmodified render. If this assertion fails, someone flipped the
+      # default — treat that as a security regression, not a config tweak.
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_REQUIRE_MEDIA_GET_AUTH
+            value: "true"
+        template: templates/deployment.yaml
+
+  - it: lets an explicit value opt out of media read auth for dev/public deployments
+    set:
+      relayUrl: wss://buzz.example.com
+      ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000"
+      externalPostgresql.url: postgres://u:p@h:5432/d
+      externalRedis.url: redis://h:6379
+      s3.endpoint: http://minio:9000
+      s3.accessKey: a
+      s3.secretKey: s
+      relay.requireMediaGetAuth: false
+    asserts:
+      - contains:
+          path: spec.template.spec.containers[0].env
+          content:
+            name: BUZZ_REQUIRE_MEDIA_GET_AUTH
+            value: "false"
+        template: templates/deployment.yaml
 
   - it: lets an explicit value disable huddle audio in a single-replica render
     set:
diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml
index 180afc6746..21548f3651 100644
--- a/deploy/charts/buzz/values.yaml
+++ b/deploy/charts/buzz/values.yaml
@@ -107,10 +107,12 @@ relay:
   sendBuffer: 1000
   requireAuthToken: true
   requireRelayMembership: true
-  # Staged rollout gate for authenticated media reads. When true, relay
-  # GET/HEAD /media/* requires Blossom kind 24242 t=get plus relay membership.
-  # Keep false until deployed desktop/mobile/CLI clients attach read auth.
-  requireMediaGetAuth: false
+  # Authenticated media reads: relay GET/HEAD /media/* requires Blossom
+  # kind 24242 t=get plus relay membership. Enabled by default so private
+  # attachments are never publicly readable by URL/hash. Only set false for
+  # local development or fully public communities — desktop, mobile, and CLI
+  # clients all attach read auth.
+  requireMediaGetAuth: true
   allowNipOaAuth: true
   pubkeyAllowlist: false
   corsOrigins: []
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 20f728e4f7..9ed8280c28 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,6 +6,7 @@ settings:
 
 overrides:
   '@radix-ui/react-dismissable-layer': 1.1.19
+  linkify-it: ^5.0.2
 
 patchedDependencies:
   isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
@@ -2756,8 +2757,8 @@ packages:
     resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
     engines: {node: '>= 12.0.0'}
 
-  linkify-it@5.0.0:
-    resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+  linkify-it@5.0.2:
+    resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
 
   linkifyjs@4.3.2:
     resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
@@ -5939,7 +5940,7 @@ snapshots:
       lightningcss-win32-arm64-msvc: 1.32.0
       lightningcss-win32-x64-msvc: 1.32.0
 
-  linkify-it@5.0.0:
+  linkify-it@5.0.2:
     dependencies:
       uc.micro: 2.1.0
 
@@ -5979,7 +5980,7 @@ snapshots:
     dependencies:
       argparse: 2.0.1
       entities: 4.5.0
-      linkify-it: 5.0.0
+      linkify-it: 5.0.2
       mdurl: 2.0.0
       punycode.js: 2.3.1
       uc.micro: 2.1.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 632a23bc2b..be122c50ce 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -12,6 +12,12 @@ overrides:
   # <body> when the dialog closed, freezing the app (#1482, reminder dialog).
   # Removable once every @radix-ui dep converges on one version naturally.
   "@radix-ui/react-dismissable-layer": 1.1.19
+  # linkify-it 5.0.0 (pulled in via markdown-it / tiptap-markdown) is affected
+  # by GHSA-22p9-wv53-3rq4 and GHSA-v245-v573-v5vm: crafted link/mailto-heavy
+  # text drives quadratic parsing and can freeze the renderer thread. Fixed in
+  # 5.0.1 / 5.0.2 respectively; pin >=5.0.2 to cover both. Removable once
+  # markdown-it resolves linkify-it >=5.0.2 on its own.
+  linkify-it: "^5.0.2"
 patchedDependencies:
   isomorphic-git: patches/isomorphic-git.patch
   virtua@0.49.3: patches/virtua@0.49.3.patch
diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh
index 105c1d547e..f6a6a93063 100755
--- a/scripts/e2e-git-perms.sh
+++ b/scripts/e2e-git-perms.sh
@@ -507,13 +507,26 @@ else
     fail "Bot2 push failed (bot should be promoted to member)"
 fi
 
-# ── Test: Non-member push denied ──────────────────────────────────────────────
+# ── Test: Non-member read denied (SEC-005) + non-member push denied ──────────
 
-log "Guest: attempting push (should be denied)..."
+log "Guest: attempting clone (should be denied — SEC-005 read gate)..."
 GUEST_DIR="$WORK_DIR/guest"
 
-git_clone "$GUEST_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$GUEST_DIR" \
-    || fail "Guest clone failed (read access should work)"
+if git_clone "$GUEST_PRIVKEY" "${RELAY_HTTP}/git/${OWNER_PUBKEY}/${REPO_NAME}" "$GUEST_DIR"; then
+    fail "Guest clone succeeded (non-member reads should be denied — SEC-005)"
+fi
+success "Guest clone denied (not a channel member) — SEC-005 read gate"
+
+# Members can still read: bot1/bot2 cloned successfully above, and the
+# owner-verification clone below re-confirms member read access post-gate.
+
+log "Guest: attempting push (should be denied)..."
+# The guest cannot clone anymore, so stage the push from a copy of bot1's
+# member clone, credentialed as the guest. (SEC-005 denies the push at the
+# receive-pack advertisement, before any ref negotiation, so the copy being
+# behind the remote does not matter.)
+rm -rf "$GUEST_DIR"
+cp -R "$BOT1_DIR" "$GUEST_DIR"
 
 echo "<!-- unauthorized -->" >> "$GUEST_DIR/index.html"
 git -C "$GUEST_DIR" add -A
@@ -523,8 +536,11 @@ git -C "$GUEST_DIR" -c user.name="Guest" -c user.email="guest@evil.test" \
 PUSH_OUTPUT=$(git_push "$GUEST_PRIVKEY" "$GUEST_DIR" 2>&1) && \
     fail "Guest push succeeded (should have been denied!)"
 
-# Verify the denial is permission-related, not a network error
-if echo "$PUSH_OUTPUT" | grep -qi "denied\|forbidden\|not authorized\|403\|permission"; then
+# Verify the denial is permission-related, not a network error. With the
+# SEC-005 read gate the denial surfaces at the receive-pack advertisement
+# as a generic "repository not found" (membership must not be probeable);
+# hook-level denials phrase it as denied/forbidden.
+if echo "$PUSH_OUTPUT" | grep -qi "denied\|forbidden\|not authorized\|403\|permission\|not found\|404"; then
     success "Guest push denied (not a channel member) — reason confirmed in output"
 else
     warn "Guest push failed but denial reason not found in output: $PUSH_OUTPUT"
@@ -884,12 +900,15 @@ fi
 # ATTACK: A malicious repo could set core.hooksPath in its .git/config to point
 # to /dev/null or a no-op script. This only affects client-side hooks — the
 # server's pre-receive hook is invoked by git-receive-pack on the SERVER, not
-# the client. This test confirms the server hook still fires.
+# the client. This test confirms server-side authorization still fires.
+# (Clone as bot1 — the SEC-005 read gate denies guest clones — then push as
+# the guest: the unauthorized push must be denied regardless of client config,
+# whether at the read gate or the server hook.)
 
 log "Hook integrity: client core.hooksPath cannot bypass server hook..."
 HOOKSPATH_DIR="$WORK_DIR/hookspath-test"
 
-NOSTR_PRIVATE_KEY="$GUEST_PRIVKEY" GIT_TERMINAL_PROMPT=0 \
+NOSTR_PRIVATE_KEY="$BOT1_PRIVKEY" GIT_TERMINAL_PROMPT=0 \
     git clone \
     -c credential.helper="" \
     -c credential.useHttpPath=true \
