diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs
index 9489630d54..7e1f316e19 100644
--- a/crates/sprout-db/src/channel.rs
+++ b/crates/sprout-db/src/channel.rs
@@ -542,6 +542,34 @@ pub async fn get_members_bulk(pool: &PgPool, channel_ids: &[Uuid]) -> Result<Vec
     rows.into_iter().map(row_to_member_record).collect()
 }
 
+/// Return the subset of `channel_ids` that refer to live, non-deleted channels.
+///
+/// This intentionally checks only channel existence/liveness, not membership. Callers use it
+/// for relay-level public-readable policy after parsing the configured allowlist.
+pub async fn filter_live_channel_ids(pool: &PgPool, channel_ids: &[Uuid]) -> Result<Vec<Uuid>> {
+    if channel_ids.is_empty() {
+        return Ok(Vec::new());
+    }
+
+    let rows = sqlx::query(
+        r#"
+        SELECT id
+        FROM channels
+        WHERE id = ANY($1) AND deleted_at IS NULL
+        "#,
+    )
+    .bind(channel_ids)
+    .fetch_all(pool)
+    .await?;
+
+    rows.into_iter()
+        .map(|r| {
+            let id: Uuid = r.try_get("id")?;
+            Ok(id)
+        })
+        .collect()
+}
+
 /// Get all channel IDs accessible to a pubkey.
 ///
 /// Includes channels where the pubkey is an active member AND all open channels.
@@ -1203,6 +1231,46 @@ mod tests {
         Keys::generate().public_key().to_bytes().to_vec()
     }
 
+    #[tokio::test]
+    #[ignore = "requires Postgres"]
+    async fn test_filter_live_channel_ids_excludes_deleted_and_missing_channels() {
+        let pool = setup_pool().await;
+        let owner_pk = random_pubkey();
+        ensure_user(&pool, &owner_pk).await.expect("ensure owner");
+
+        let live = create_channel(
+            &pool,
+            &format!("test-public-live-{}", Uuid::new_v4()),
+            ChannelType::Stream,
+            ChannelVisibility::Private,
+            None,
+            &owner_pk,
+            None,
+        )
+        .await
+        .expect("create live channel");
+        let deleted = create_channel(
+            &pool,
+            &format!("test-public-deleted-{}", Uuid::new_v4()),
+            ChannelType::Stream,
+            ChannelVisibility::Private,
+            None,
+            &owner_pk,
+            None,
+        )
+        .await
+        .expect("create deleted channel");
+        soft_delete_channel(&pool, deleted.id)
+            .await
+            .expect("soft delete channel");
+
+        let result = filter_live_channel_ids(&pool, &[live.id, deleted.id, Uuid::new_v4()])
+            .await
+            .expect("filter live channel ids");
+
+        assert_eq!(result, vec![live.id]);
+    }
+
     /// Agent owner (non-admin) can remove their own bot from a channel.
     #[tokio::test]
     #[ignore = "requires Postgres"]
diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs
index e897d6035a..ebb3e97d50 100644
--- a/crates/sprout-db/src/event.rs
+++ b/crates/sprout-db/src/event.rs
@@ -218,25 +218,23 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
         qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
     }
 
-    // Multi-channel IN pushdown: restrict to events in any of these channels
-    // OR global events (channel_id IS NULL). Used by NIP-45 COUNT to enforce
-    // channel access at the SQL level without fetching all rows.
+    // Multi-channel IN pushdown: restrict to events in any of these channels.
+    // Used by NIP-45 COUNT global filters to enforce channel access at SQL level
+    // without broadening public-readable channel access to global events.
     //
-    // SECURITY: Some(empty vec) means "user has access to NO channels" —
-    // only global events (channel_id IS NULL) should be returned.
+    // SECURITY: Some(empty vec) means "match no channel-scoped events" — return
+    // empty immediately. Callers that intentionally include global events should
+    // query them separately via `global_only`.
     if let Some(ref ch_ids) = q.channel_ids {
         if ch_ids.is_empty() {
-            // No channel access — only global (non-channel) events visible.
-            qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
+            return Ok(vec![]);
         } else {
-            qb.push(format!(
-                " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
-            ));
+            qb.push(format!(" AND {col_prefix}channel_id IN ("));
             let mut sep = qb.separated(", ");
             for ch in ch_ids {
                 sep.push_bind(*ch);
             }
-            qb.push("))");
+            qb.push(")");
         }
     }
 
@@ -438,20 +436,19 @@ pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result<i64> {
         qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
     }
 
-    // Multi-channel IN pushdown for COUNT: restrict to accessible channels + global.
-    // SECURITY: Some(empty vec) = no channel access → global events only.
+    // Multi-channel IN pushdown for COUNT: restrict to accessible channel-scoped
+    // events only. Global events require an explicit `global_only` query.
+    // SECURITY: Some(empty vec) = no channel access → count nothing.
     if let Some(ref ch_ids) = q.channel_ids {
         if ch_ids.is_empty() {
-            qb.push(format!(" AND {col_prefix}channel_id IS NULL"));
+            return Ok(0);
         } else {
-            qb.push(format!(
-                " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
-            ));
+            qb.push(format!(" AND {col_prefix}channel_id IN ("));
             let mut sep = qb.separated(", ");
             for ch in ch_ids {
                 sep.push_bind(*ch);
             }
-            qb.push("))");
+            qb.push(")");
         }
     }
 
@@ -1064,6 +1061,35 @@ mod tests {
         assert_eq!(extract_d_tag(&above), None);
     }
 
+    #[test]
+    fn channel_ids_pushdown_is_channel_scoped_not_global() {
+        let ch = uuid::Uuid::new_v4();
+        let q = EventQuery {
+            channel_ids: Some(vec![ch]),
+            ..Default::default()
+        };
+
+        assert_eq!(q.channel_ids.as_deref(), Some(&[ch][..]));
+        assert!(
+            !q.global_only,
+            "channel_ids pushdown is channel-scoped only; global reads require explicit global_only"
+        );
+    }
+
+    #[test]
+    fn empty_channel_ids_pushdown_matches_no_channel_events() {
+        let q = EventQuery {
+            channel_ids: Some(vec![]),
+            ..Default::default()
+        };
+
+        assert_eq!(q.channel_ids.as_deref(), Some(&[][..]));
+        assert!(
+            !q.global_only,
+            "an empty effective readable set must not silently broaden to global events"
+        );
+    }
+
     #[test]
     fn extract_d_tag_single_element_d_tag_ignored() {
         // A d tag with only one element (no value) should not match — parts.len() < 2
diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs
index 5c2449e726..f00491863d 100644
--- a/crates/sprout-db/src/lib.rs
+++ b/crates/sprout-db/src/lib.rs
@@ -442,6 +442,11 @@ impl Db {
         channel::get_accessible_channel_ids(&self.pool, pubkey).await
     }
 
+    /// Return the subset of channel IDs that exist and have not been soft-deleted.
+    pub async fn filter_live_channel_ids(&self, channel_ids: &[Uuid]) -> Result<Vec<Uuid>> {
+        channel::filter_live_channel_ids(&self.pool, channel_ids).await
+    }
+
     /// Lists channels, optionally filtered by visibility.
     pub async fn list_channels(
         &self,
@@ -695,8 +700,17 @@ impl Db {
         depth_limit: Option<u32>,
         limit: u32,
         cursor: Option<&[u8]>,
+        channel_id: Option<Uuid>,
     ) -> Result<Vec<thread::ThreadReply>> {
-        thread::get_thread_replies(&self.pool, root_event_id, depth_limit, limit, cursor).await
+        thread::get_thread_replies(
+            &self.pool,
+            root_event_id,
+            depth_limit,
+            limit,
+            cursor,
+            channel_id,
+        )
+        .await
     }
 
     /// Fetch aggregated thread stats.
diff --git a/crates/sprout-db/src/thread.rs b/crates/sprout-db/src/thread.rs
index 686530e5db..e2aec375b2 100644
--- a/crates/sprout-db/src/thread.rs
+++ b/crates/sprout-db/src/thread.rs
@@ -316,6 +316,7 @@ pub async fn decrement_reply_count(
 /// - `cursor` -- if `Some(ts_bytes)`, returns replies with `event_created_at`
 ///   strictly after the timestamp encoded in `ts_bytes`. The bytes must be an
 ///   8-byte big-endian i64 Unix timestamp in seconds.
+/// - `channel_id` -- if `Some`, returns only replies in that channel.
 /// - `limit` -- maximum rows returned (caller should cap this).
 pub async fn get_thread_replies(
     pool: &PgPool,
@@ -323,6 +324,7 @@ pub async fn get_thread_replies(
     depth_limit: Option<u32>,
     limit: u32,
     cursor: Option<&[u8]>,
+    channel_id: Option<Uuid>,
 ) -> Result<Vec<ThreadReply>> {
     // Decode cursor bytes -> DateTime<Utc> for the keyset condition.
     let cursor_ts: Option<DateTime<Utc>> = match cursor {
@@ -367,6 +369,10 @@ pub async fn get_thread_replies(
         sql.push_str(&format!(" AND tm.event_created_at > ${param_idx}"));
         param_idx += 1;
     }
+    if channel_id.is_some() {
+        sql.push_str(&format!(" AND tm.channel_id = ${param_idx}"));
+        param_idx += 1;
+    }
 
     sql.push_str(&format!(
         " ORDER BY tm.event_created_at ASC LIMIT ${param_idx}"
@@ -380,6 +386,9 @@ pub async fn get_thread_replies(
     if let Some(ts) = cursor_ts {
         q = q.bind(ts);
     }
+    if let Some(ch_id) = channel_id {
+        q = q.bind(ch_id);
+    }
     q = q.bind(limit as i32);
 
     let rows = q.fetch_all(pool).await?;
diff --git a/crates/sprout-relay/src/api/bridge.rs b/crates/sprout-relay/src/api/bridge.rs
index ab916524b4..5922350756 100644
--- a/crates/sprout-relay/src/api/bridge.rs
+++ b/crates/sprout-relay/src/api/bridge.rs
@@ -258,9 +258,9 @@ pub async fn query_events(
         ));
     }
 
-    // Get channels this user can access — same enforcement as WS REQ handler.
+    // Effective authenticated read set = normal access ∪ live public-readable allowlist.
     let accessible_channels = state
-        .get_accessible_channel_ids_cached(&pubkey_bytes)
+        .effective_readable_channel_ids(&pubkey_bytes, None)
         .await
         .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
 
@@ -365,11 +365,17 @@ pub async fn query_events(
             _ => continue,
         };
 
-        if let Some(ch_id) = extract_channel_from_filter(filter) {
-            if !accessible_channels.contains(&ch_id) {
-                handled.insert(idx);
-                continue;
-            }
+        let Some(ch_id) = extract_channel_from_filter(filter) else {
+            handled.insert(idx);
+            continue;
+        };
+        // Bridge is authenticated-only today, so `accessible_channels` is already the
+        // effective read set. If unauthenticated/public bridge reads are added later,
+        // they must resolve the same live public allowlist before this access check;
+        // never fall back to unscoped thread expansion for `depth_limit`.
+        if !accessible_channels.contains(&ch_id) {
+            handled.insert(idx);
+            continue;
         }
 
         let limit = filter
@@ -378,7 +384,7 @@ pub async fn query_events(
             .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32;
         let thread_replies = state
             .db
-            .get_thread_replies(&root_bytes, Some(depth), limit, None)
+            .get_thread_replies(&root_bytes, Some(depth), limit, None, Some(ch_id))
             .await
             .map_err(|e| internal_error(&format!("thread query error: {e}")))?;
 
@@ -415,9 +421,7 @@ pub async fn query_events(
             }
         }
 
-        let mut query =
-            crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state)
-                .await;
+        let mut query = crate::handlers::req::build_event_query_from_filter(filter);
 
         if let Some(bid) = extract_before_id(raw) {
             if query.until.is_none() {
@@ -495,9 +499,9 @@ pub async fn count_events(
         ));
     }
 
-    // Get channels this user can access.
+    // Effective authenticated read set = normal access ∪ live public-readable allowlist.
     let accessible_channels = state
-        .get_accessible_channel_ids_cached(&pubkey_bytes)
+        .effective_readable_channel_ids(&pubkey_bytes, None)
         .await
         .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?;
 
@@ -509,9 +513,7 @@ pub async fn count_events(
                 continue; // Skip filters targeting inaccessible channels.
             }
             // Channel is accessible — count with pushability check.
-            let query =
-                crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state)
-                    .await;
+            let query = crate::handlers::req::build_event_query_from_filter(filter);
             if crate::handlers::req::filter_fully_pushable(filter) {
                 match state.db.count_events(&query).await {
                     Ok(n) => total += n as u64,
@@ -541,9 +543,7 @@ pub async fn count_events(
         } else {
             // No channel filter — use SQL-level channel_ids pushdown to count
             // only events in accessible channels (+ global events).
-            let mut query =
-                crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state)
-                    .await;
+            let mut query = crate::handlers::req::build_event_query_from_filter(filter);
             query.channel_ids = Some(accessible_channels.to_vec());
 
             if crate::handlers::req::filter_fully_pushable(filter) {
@@ -1182,6 +1182,26 @@ mod tests {
         assert!(extract_feed_types(&raw).is_none());
     }
 
+    #[test]
+    fn depth_limited_thread_filter_without_channel_is_not_channel_accessible() {
+        let filter = nostr::Filter::new().event(nostr::EventId::from_slice(&[1u8; 32]).unwrap());
+
+        assert!(
+            extract_channel_from_filter(&filter).is_none(),
+            "depth_limit bridge path must fail closed unless caller supplies #h"
+        );
+    }
+
+    #[test]
+    fn depth_limited_thread_filter_with_channel_extracts_channel() {
+        let ch = uuid::Uuid::new_v4();
+        let filter = nostr::Filter::new()
+            .event(nostr::EventId::from_slice(&[1u8; 32]).unwrap())
+            .custom_tag(SingleLetterTag::lowercase(Alphabet::H), ch.to_string());
+
+        assert_eq!(extract_channel_from_filter(&filter), Some(ch));
+    }
+
     #[test]
     fn event_accessible_no_channel() {
         let keys = Keys::generate();
diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs
index 4376d89b90..eee564825c 100644
--- a/crates/sprout-relay/src/config.rs
+++ b/crates/sprout-relay/src/config.rs
@@ -92,6 +92,15 @@ pub struct Config {
     /// Media storage configuration (S3/MinIO).
     pub media: sprout_media::MediaConfig,
 
+    /// Comma-separated channel UUIDs that are readable without channel membership.
+    ///
+    /// Public-readable channels are a read-only relay policy: they expand REQ/COUNT/query
+    /// access only after being validated as live, non-deleted channels by the DB. They never
+    /// grant write, upload, admin, or membership privileges. Invalid UUIDs fail closed by
+    /// being ignored.
+    /// Set via `SPROUT_PUBLIC_READABLE_CHANNELS`.
+    pub public_readable_channels: Vec<uuid::Uuid>,
+
     /// Optional override for ephemeral channel TTL (in seconds).
     /// When set, any channel created with a TTL tag will use this value instead
     /// of the client-provided one. Useful for testing ephemeral expiry quickly.
@@ -121,6 +130,22 @@ pub struct Config {
     pub web_dir: Option<std::path::PathBuf>,
 }
 
+fn parse_public_readable_channels() -> Vec<uuid::Uuid> {
+    std::env::var("SPROUT_PUBLIC_READABLE_CHANNELS")
+        .unwrap_or_default()
+        .split(',')
+        .map(str::trim)
+        .filter(|s| !s.is_empty())
+        .filter_map(|s| match s.parse::<uuid::Uuid>() {
+            Ok(id) if id != uuid::Uuid::nil() => Some(id),
+            _ => {
+                warn!(channel_id = %s, "Ignoring invalid SPROUT_PUBLIC_READABLE_CHANNELS entry");
+                None
+            }
+        })
+        .collect()
+}
+
 impl Config {
     /// Loads configuration from environment variables, falling back to development defaults.
     pub fn from_env() -> Result<Self, ConfigError> {
@@ -277,6 +302,8 @@ impl Config {
                 }),
         };
 
+        let public_readable_channels = parse_public_readable_channels();
+
         let ephemeral_ttl_override = std::env::var("SPROUT_EPHEMERAL_TTL_OVERRIDE")
             .ok()
             .and_then(|v| v.parse::<i32>().ok())
@@ -376,6 +403,7 @@ impl Config {
             relay_owner_pubkey,
             allow_nip_oa_auth,
             media,
+            public_readable_channels,
             ephemeral_ttl_override,
             git_repo_path,
             git_max_pack_bytes,
@@ -421,6 +449,26 @@ mod tests {
             !config.allow_nip_oa_auth,
             "allow_nip_oa_auth should default to false"
         );
+        assert!(
+            config.public_readable_channels.is_empty(),
+            "public_readable_channels should default empty"
+        );
+    }
+
+    #[test]
+    fn public_readable_channels_parses_valid_uuids_and_ignores_invalid_entries() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let valid_a = uuid::Uuid::new_v4();
+        let valid_b = uuid::Uuid::new_v4();
+        std::env::set_var(
+            "SPROUT_PUBLIC_READABLE_CHANNELS",
+            format!(" {valid_a},not-a-uuid,00000000-0000-0000-0000-000000000000,{valid_b} "),
+        );
+
+        let config = Config::from_env().expect("config");
+        std::env::remove_var("SPROUT_PUBLIC_READABLE_CHANNELS");
+
+        assert_eq!(config.public_readable_channels, vec![valid_a, valid_b]);
     }
 
     #[test]
diff --git a/crates/sprout-relay/src/handlers/count.rs b/crates/sprout-relay/src/handlers/count.rs
index 22331c9c8d..3fe39d16bc 100644
--- a/crates/sprout-relay/src/handlers/count.rs
+++ b/crates/sprout-relay/src/handlers/count.rs
@@ -29,47 +29,71 @@ pub async fn handle_count(
     conn: Arc<ConnectionState>,
     state: Arc<AppState>,
 ) {
-    // Require auth
     let pubkey_bytes = {
         let auth = conn.auth_state.read().await;
         match &*auth {
-            AuthState::Authenticated(ctx) => ctx.pubkey.to_bytes().to_vec(),
-            _ => {
-                conn.send(RelayMessage::closed(
-                    &sub_id,
-                    "auth-required: not authenticated",
-                ));
-                return;
-            }
+            AuthState::Authenticated(ctx) => Some(ctx.pubkey.to_bytes().to_vec()),
+            _ => None,
         }
     };
 
-    // P-gated kinds (gift wraps, member notifications, observer frames) require
-    // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler.
-    let authed_pubkey_hex = hex::encode(&pubkey_bytes);
-    if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
-        conn.send(RelayMessage::closed(
-            &sub_id,
-            "restricted: p-gated kinds require #p tag matching your pubkey",
-        ));
-        return;
-    }
-    if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) {
-        conn.send(RelayMessage::closed(
-            &sub_id,
-            "restricted: agent-engram reads require authors=[self] or #p=[self]",
-        ));
-        return;
-    }
+    let accessible_channels = if let Some(pubkey_bytes) = pubkey_bytes.as_deref() {
+        // P-gated kinds (gift wraps, member notifications, observer frames) require
+        // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler.
+        let authed_pubkey_hex = hex::encode(pubkey_bytes);
+        if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "restricted: p-gated kinds require #p tag matching your pubkey",
+            ));
+            return;
+        }
+        if !super::req::engram_filters_authorized(&filters, &authed_pubkey_hex) {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "restricted: agent-engram reads require authors=[self] or #p=[self]",
+            ));
+            return;
+        }
 
-    // Get channels this user can access — same enforcement as WS REQ handler.
-    let accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await {
-        Ok(ids) => ids,
-        Err(e) => {
-            warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}");
-            conn.send(RelayMessage::closed(&sub_id, "error: database error"));
+        match state
+            .effective_readable_channel_ids(pubkey_bytes, None)
+            .await
+        {
+            Ok(ids) => ids,
+            Err(e) => {
+                warn!(sub_id = %sub_id, "Failed to get effective readable channels: {e}");
+                conn.send(RelayMessage::closed(&sub_id, "error: database error"));
+                return;
+            }
+        }
+    } else {
+        let public_readable_channels = match state.public_readable_channel_ids().await {
+            Ok(ids) => ids,
+            Err(e) => {
+                warn!(sub_id = %sub_id, "Failed to resolve public-readable channels: {e}");
+                conn.send(RelayMessage::closed(&sub_id, "error: database error"));
+                return;
+            }
+        };
+        if public_readable_channels.is_empty() {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "auth-required: not authenticated",
+            ));
+            return;
+        }
+        if filters
+            .iter()
+            .any(|filter| extract_channel_from_filter(filter).is_none())
+        {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "restricted: public counts require #h channel scope",
+            ));
             return;
         }
+        public_readable_channels
     };
 
     // For each filter, count matching events with channel access enforcement.
@@ -81,8 +105,7 @@ pub async fn handle_count(
                 continue; // Skip filters targeting inaccessible channels.
             }
             // Channel is accessible — count with pushability check.
-            let query =
-                super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
+            let query = super::req::build_event_query_from_filter(filter);
             if super::req::filter_fully_pushable(filter) {
                 match state.db.count_events(&query).await {
                     Ok(n) => total += n as u64,
@@ -118,8 +141,7 @@ pub async fn handle_count(
             // If the filter has generic tags beyond what SQL can push down
             // (#h, #p single, #d single, #e), we must fall back to
             // query + post-filter to avoid overcounting.
-            let mut query =
-                super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await;
+            let mut query = super::req::build_event_query_from_filter(filter);
             query.channel_ids = Some(accessible_channels.to_vec());
 
             if super::req::filter_fully_pushable(filter) {
diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs
index 0c6ca73466..61a20ee2d5 100644
--- a/crates/sprout-relay/src/handlers/req.rs
+++ b/crates/sprout-relay/src/handlers/req.rs
@@ -36,7 +36,8 @@ pub async fn handle_req(
     conn: Arc<ConnectionState>,
     state: Arc<AppState>,
 ) {
-    let (conn_id, pubkey_bytes, token_channel_ids) = {
+    let conn_id = conn.conn_id;
+    let (pubkey_bytes, token_channel_ids) = {
         let auth = conn.auth_state.read().await;
         match &*auth {
             AuthState::Authenticated(ctx) => {
@@ -49,46 +50,69 @@ pub async fn handle_req(
                     return;
                 }
 
-                let pk_bytes = ctx.pubkey.to_bytes().to_vec();
+                (
+                    Some(ctx.pubkey.to_bytes().to_vec()),
+                    ctx.channel_ids.clone(),
+                )
+            }
+            _ => (None, None),
+        }
+    };
 
-                let subs = conn.subscriptions.lock().await;
-                if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS {
-                    conn.send(RelayMessage::closed(
-                        &sub_id,
-                        "error: too many subscriptions",
-                    ));
-                    return;
-                }
+    let subs = conn.subscriptions.lock().await;
+    if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS {
+        conn.send(RelayMessage::closed(
+            &sub_id,
+            "error: too many subscriptions",
+        ));
+        return;
+    }
+    drop(subs);
+
+    let channel_id = extract_channel_id_from_filters(&filters);
 
-                (conn.conn_id, pk_bytes, ctx.channel_ids.clone())
+    let (accessible_channels, include_global) = if let Some(pubkey_bytes) = pubkey_bytes.as_deref()
+    {
+        let ids = match state
+            .effective_readable_channel_ids(pubkey_bytes, token_channel_ids.as_deref())
+            .await
+        {
+            Ok(ids) => ids,
+            Err(e) => {
+                warn!(conn_id = %conn_id, "Failed to get effective readable channels: {e}");
+                conn.send(RelayMessage::closed(&sub_id, "error: database error"));
+                return;
             }
-            _ => {
-                conn.send(RelayMessage::notice(
-                    "auth-required: authenticate before subscribing",
-                ));
-                conn.send(RelayMessage::closed(
-                    &sub_id,
-                    "auth-required: not authenticated",
-                ));
+        };
+        (ids, token_channel_ids.is_none())
+    } else {
+        let public_readable_channels = match state.public_readable_channel_ids().await {
+            Ok(ids) => ids,
+            Err(e) => {
+                warn!(conn_id = %conn_id, "Failed to resolve public-readable channels: {e}");
+                conn.send(RelayMessage::closed(&sub_id, "error: database error"));
                 return;
             }
+        };
+        if public_readable_channels.is_empty() {
+            conn.send(RelayMessage::notice(
+                "auth-required: authenticate before subscribing",
+            ));
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "auth-required: not authenticated",
+            ));
+            return;
         }
-    };
-
-    let mut accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await
-    {
-        Ok(ids) => ids,
-        Err(e) => {
-            warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}");
-            conn.send(RelayMessage::closed(&sub_id, "error: database error"));
+        if channel_id.is_none() {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "restricted: public reads require #h channel scope",
+            ));
             return;
         }
+        (public_readable_channels, false)
     };
-    if let Some(allowed) = token_channel_ids.as_deref() {
-        accessible_channels.retain(|channel_id| allowed.contains(channel_id));
-    }
-
-    let channel_id = extract_channel_id_from_filters(&filters);
 
     // ── #p / engram gating for globally-stored sensitive kinds ───────────────
     // Applied BEFORE the NIP-50 search branch so that an authenticated member
@@ -100,7 +124,14 @@ pub async fn handle_req(
     // channel-scoped subs can never receive globally-stored events because of
     // the fan_out() invariant in subscription.rs.
     if channel_id.is_none() {
-        let authed_pubkey_hex = hex::encode(&pubkey_bytes);
+        let Some(pubkey_bytes) = pubkey_bytes.as_deref() else {
+            conn.send(RelayMessage::closed(
+                &sub_id,
+                "restricted: public reads require #h channel scope",
+            ));
+            return;
+        };
+        let authed_pubkey_hex = hex::encode(pubkey_bytes);
         if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) {
             conn.send(RelayMessage::closed(
                 &sub_id,
@@ -135,7 +166,7 @@ pub async fn handle_req(
             &sub_id,
             &filters,
             &accessible_channels,
-            token_channel_ids.is_none(),
+            include_global,
             &conn,
             &state,
         )
@@ -454,12 +485,7 @@ async fn handle_search_req(
 /// Convert a single NIP-01 filter into an [`EventQuery`] for the database.
 ///
 /// Public wrapper for use by the HTTP bridge and COUNT handler.
-/// Resolves accessible channels for the given pubkey and builds the query.
-pub async fn build_event_query_from_filter(
-    filter: &Filter,
-    _pubkey_bytes: &[u8],
-    _state: &AppState,
-) -> EventQuery {
+pub fn build_event_query_from_filter(filter: &Filter) -> EventQuery {
     let channel_id = extract_channel_id_from_filter(filter);
     filter_to_query_params(filter, channel_id)
 }
@@ -947,6 +973,46 @@ mod tests {
         );
     }
 
+    #[test]
+    fn public_viewer_search_scope_excludes_global_results() {
+        let public_channel = uuid::Uuid::new_v4();
+
+        let scope = build_search_channel_scope_filter(&[public_channel], false)
+            .expect("public viewer should search configured channel");
+
+        assert_eq!(scope, format!("channel_id:=[{public_channel}]"));
+        assert!(
+            !scope.contains("__global__"),
+            "anonymous public reads must not include global search hits"
+        );
+    }
+
+    #[test]
+    fn extend_unique_unions_public_readable_channels_without_duplicates() {
+        let member_channel = uuid::Uuid::new_v4();
+        let shared_channel = uuid::Uuid::new_v4();
+        let public_only = uuid::Uuid::new_v4();
+        let mut channels = vec![member_channel, shared_channel];
+
+        crate::state::extend_unique_channel_ids(&mut channels, &[shared_channel, public_only]);
+
+        assert_eq!(channels, vec![member_channel, shared_channel, public_only]);
+    }
+
+    #[test]
+    fn non_allowlisted_non_member_channel_filter_matches_nothing() {
+        let non_allowlisted = uuid::Uuid::new_v4();
+        let other_readable = uuid::Uuid::new_v4();
+        let filter = filter_with_channel(non_allowlisted);
+
+        let per_filter_channel = extract_channel_id_from_filter(&filter);
+        assert_eq!(per_filter_channel, Some(non_allowlisted));
+        assert!(
+            ![other_readable].contains(&non_allowlisted),
+            "handler must reject #h filters outside the effective readable set"
+        );
+    }
+
     // ── NIP-AE engram read gating ────────────────────────────────────────
 
     /// Three real x-only pubkeys (valid for `PublicKey::from_hex`). Distinct,
diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs
index 67e75fedb4..c9191e4a69 100644
--- a/crates/sprout-relay/src/nip11.rs
+++ b/crates/sprout-relay/src/nip11.rs
@@ -59,21 +59,24 @@ pub struct RelayLimitation {
     /// Minimum proof-of-work difficulty required for events.
     pub min_pow_difficulty: Option<u32>,
     /// Whether NIP-42 authentication is required before subscribing or
-    /// publishing events.
+    /// publishing events. False only when unauthenticated reads are enabled for
+    /// the configured public channel allowlist; all writes still require auth.
     pub auth_required: bool,
     /// Whether payment is required to use the relay.
     pub payment_required: bool,
-    /// Whether writes are restricted to authorized pubkeys.
+    /// Whether writes are restricted to authorized pubkeys. Always true: public
+    /// readability never grants EVENT/write permission.
     pub restricted_writes: bool,
 }
 
 /// Canonical `RelayLimitation` advertised by this relay.
 ///
-/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers
-/// unconditionally reject connections that are not in
-/// `AuthState::Authenticated`. This is independent of the REST API token
-/// toggle (`config.require_auth_token`).
-fn relay_limitation() -> RelayLimitation {
+/// Build the relay limitation block.
+///
+/// `auth_required` is false when public-readable channels are configured because
+/// unauthenticated REQ/COUNT are allowed for that constrained channel allowlist.
+/// EVENT/write paths remain authenticated-only via `restricted_writes=true`.
+fn relay_limitation(public_read_enabled: bool) -> RelayLimitation {
     RelayLimitation {
         max_message_length: Some(MAX_FRAME_BYTES as u64),
         max_subscriptions: Some(1024),
@@ -81,7 +84,7 @@ fn relay_limitation() -> RelayLimitation {
         max_limit: Some(10_000),
         max_subid_length: Some(256),
         min_pow_difficulty: None,
-        auth_required: true,
+        auth_required: !public_read_enabled,
         payment_required: false,
         restricted_writes: true,
     }
@@ -102,7 +105,11 @@ impl RelayInfo {
     /// gates on NIP-43 events — i.e. has a stable key AND enforces
     /// membership. NIP-43 events are verified against `self`, so it is a
     /// programmer error to advertise NIP-43 without a `relay_self`.
-    pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self {
+    pub fn build(
+        relay_self: Option<&str>,
+        advertise_nip43: bool,
+        public_read_enabled: bool,
+    ) -> Self {
         debug_assert!(
             !advertise_nip43 || relay_self.is_some(),
             "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`"
@@ -121,7 +128,7 @@ impl RelayInfo {
             supported_nips,
             software: "https://github.com/block/sprout".to_string(),
             version: env!("CARGO_PKG_VERSION").to_string(),
-            limitation: Some(relay_limitation()),
+            limitation: Some(relay_limitation(public_read_enabled)),
             relay_self: relay_self.map(|s| s.to_string()),
         }
     }
@@ -131,8 +138,12 @@ impl RelayInfo {
 pub async fn relay_info_handler(
     axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
 ) -> axum::response::Json<RelayInfo> {
-    let (relay_self, advertise_nip43) = nip11_facts(&state);
-    axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43))
+    let (relay_self, advertise_nip43, public_read_enabled) = nip11_facts(&state);
+    axum::response::Json(RelayInfo::build(
+        relay_self.as_deref(),
+        advertise_nip43,
+        public_read_enabled,
+    ))
 }
 
 /// Derives the two NIP-11 facts that depend on runtime config:
@@ -147,11 +158,12 @@ pub async fn relay_info_handler(
 ///
 /// Centralised so the content-negotiated root handler and the dedicated
 /// `/info` endpoint can't drift apart.
-pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bool) {
+pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bool, bool) {
     let has_stable_key = state.config.relay_private_key.is_some();
     let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex());
     let advertise_nip43 = has_stable_key && state.config.require_relay_membership;
-    (relay_self, advertise_nip43)
+    let public_read_enabled = !state.config.public_readable_channels.is_empty();
+    (relay_self, advertise_nip43, public_read_enabled)
 }
 
 #[cfg(test)]
@@ -185,7 +197,12 @@ mod tests {
         // REQ, EVENT, and COUNT all unconditionally require
         // `AuthState::Authenticated` (see `crates/sprout-relay/src/handlers/`),
         // so the NIP-11 doc must advertise it.
-        assert!(relay_limitation().auth_required);
+        assert!(relay_limitation(false).auth_required);
+    }
+
+    #[test]
+    fn auth_required_is_advertised_false_when_public_read_enabled() {
+        assert!(!relay_limitation(true).auth_required);
     }
 
     #[test]
@@ -214,7 +231,7 @@ mod tests {
     /// Open relay, ephemeral key — both `self` and NIP-43 are absent.
     #[test]
     fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
-        let info = RelayInfo::build(None, false);
+        let info = RelayInfo::build(None, false, false);
         assert!(info.relay_self.is_none());
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -227,7 +244,7 @@ mod tests {
     #[test]
     fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), false);
+        let info = RelayInfo::build(Some(pk), false, false);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -236,7 +253,7 @@ mod tests {
     #[test]
     fn build_membership_relay_advertises_self_and_nip43() {
         let pk = "0000000000000000000000000000000000000000000000000000000000000001";
-        let info = RelayInfo::build(Some(pk), true);
+        let info = RelayInfo::build(Some(pk), true, false);
         assert_eq!(info.relay_self.as_deref(), Some(pk));
         assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
     }
@@ -247,6 +264,6 @@ mod tests {
     #[test]
     #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
     fn build_nip43_without_self_panics_in_debug() {
-        let _ = RelayInfo::build(None, true);
+        let _ = RelayInfo::build(None, true, false);
     }
 }
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index f72516bf31..ff932b8d2b 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -153,10 +153,10 @@ async fn nip11_or_ws_handler(
         .and_then(|v| v.to_str().ok())
         .unwrap_or("");
 
-    let (relay_self, advertise_nip43) = nip11_facts(&state);
+    let (relay_self, advertise_nip43, public_read_enabled) = nip11_facts(&state);
 
     if accept.contains("application/nostr+json") {
-        let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43);
+        let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43, public_read_enabled);
         return Json(info).into_response();
     }
 
@@ -175,7 +175,8 @@ async fn nip11_or_ws_handler(
                 }
             }
             // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
-            let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43);
+            let info =
+                RelayInfo::build(relay_self.as_deref(), advertise_nip43, public_read_enabled);
             Json(info).into_response()
         }
     }
diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs
index 597ba61817..1d033dcefd 100644
--- a/crates/sprout-relay/src/state.rs
+++ b/crates/sprout-relay/src/state.rs
@@ -445,6 +445,35 @@ impl AppState {
         self.accessible_channels_cache.invalidate_all();
     }
 
+    /// Resolve configured public-readable channels to live, non-deleted channel IDs.
+    ///
+    /// DB errors fail closed by returning the error to the caller; invalid UUIDs are
+    /// already dropped during config parsing.
+    pub async fn public_readable_channel_ids(&self) -> Result<Vec<Uuid>, sprout_db::DbError> {
+        self.db
+            .filter_live_channel_ids(&self.config.public_readable_channels)
+            .await
+    }
+
+    /// Get the effective channel read set for an authenticated caller.
+    ///
+    /// This is the single source of truth for authenticated reads: normal
+    /// membership/open-channel access, optionally narrowed by an API-token channel
+    /// allowlist, unioned with the relay's live public-readable channel policy.
+    pub async fn effective_readable_channel_ids(
+        &self,
+        pubkey: &[u8],
+        token_channel_ids: Option<&[Uuid]>,
+    ) -> Result<Vec<Uuid>, sprout_db::DbError> {
+        let mut ids = self.get_accessible_channel_ids_cached(pubkey).await?;
+        if let Some(allowed) = token_channel_ids {
+            ids.retain(|channel_id| allowed.contains(channel_id));
+        }
+        let public_readable = self.public_readable_channel_ids().await?;
+        extend_unique_channel_ids(&mut ids, &public_readable);
+        Ok(ids)
+    }
+
     /// Get accessible channel IDs with a 10-second cache. Falls back to DB on miss.
     pub async fn get_accessible_channel_ids_cached(
         &self,
@@ -462,6 +491,15 @@ impl AppState {
     }
 }
 
+/// Extend a channel-id set while preserving order and avoiding duplicates.
+pub(crate) fn extend_unique_channel_ids(channels: &mut Vec<Uuid>, extra: &[Uuid]) {
+    for id in extra {
+        if !channels.contains(id) {
+            channels.push(*id);
+        }
+    }
+}
+
 /// Handle for graceful audit worker shutdown.
 ///
 /// Signals the worker to stop accepting new entries, drain its buffer,
diff --git a/crates/sprout-relay/src/subscription.rs b/crates/sprout-relay/src/subscription.rs
index f0cee16510..134028cd19 100644
--- a/crates/sprout-relay/src/subscription.rs
+++ b/crates/sprout-relay/src/subscription.rs
@@ -871,6 +871,29 @@ mod tests {
         assert_eq!(matches[0].0, conn_id);
     }
 
+    #[test]
+    fn authenticated_global_sub_does_not_fanout_public_channel_events() {
+        // Public-readable channels extend explicit channel reads only. An authenticated
+        // non-member's global subscription is still registered as channel_id=None, so
+        // fan-out must not deliver channel-scoped events from the public allowlist.
+        let registry = SubscriptionRegistry::new();
+        let conn_id = Uuid::new_v4();
+        let public_channel = Uuid::new_v4();
+
+        registry.register(
+            conn_id,
+            "global_public_reader".to_string(),
+            vec![Filter::new().kind(Kind::TextNote)],
+            None,
+        );
+
+        let public_channel_event = make_stored_event(Kind::TextNote, Some(public_channel));
+        assert!(
+            registry.fan_out(&public_channel_event).is_empty(),
+            "global authenticated subscriptions must not receive public allowlist channel events"
+        );
+    }
+
     #[test]
     fn test_empty_kinds_array_remove_is_noop() {
         // Removing a kinds:[] subscription should not panic or corrupt the index.
