diff --git a/README.md b/README.md
index 83d2f0963699fdb47e37db5088cd5e6d21644f97..a26271978372a1f819a1899e1d3254d04ebb062a 100644
--- a/README.md
+++ b/README.md
@@ -334,3 +334,17 @@
 preference handling, CSRF checks, and forms that operate without JavaScript.
 Read the [repository watches architectural decision record](docs/adr/0018-repository-watches.md)
 for the storage and privacy contracts.
+
+## Milestone 4.4 gate
+
+Run the scoped feed gate:
+
+```text
+./scripts/check-m4-4
+```
+
+This command tests public issue feeds, hash-only feed tokens, one-time token
+display, repository and personalized scopes, current private-repository access,
+rotation, revocation, stable event selection, and Atom and RSS parsing. Read the
+[scoped feeds architectural decision record](docs/adr/0019-scoped-feeds.md) for
+the token, authorization, and ordering contracts.
diff --git a/docs/adr/0019-scoped-feeds.md b/docs/adr/0019-scoped-feeds.md
new file mode 100644
index 0000000000000000000000000000000000000000..a4113024d740c3d7ed0c744da9f421217dec48aa
--- /dev/null
+++ b/docs/adr/0019-scoped-feeds.md
@@ -1,0 +1,87 @@
+# Architectural decision record 0019: Scoped feeds
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+Public issue activity needs Atom and RSS feeds. An account also needs feeds for
+one private repository, watched activity, assignments, and mentions. A feed
+reader cannot use the interactive Web login. Thus, a private feed URL is a
+credential and must have a narrow authority.
+
+## Decision
+
+Add public issue feeds at these paths:
+
+```text
+/{owner}/{repository}/issues/atom.xml
+/{owner}/{repository}/issues/rss.xml
+```
+
+Select only issue events from the canonical repository event stream. Keep the
+entry ID and repository sequence unchanged. Thus, edits and restarts do not
+change the order or identity of an entry.
+
+Use a random 256-bit token for each private feed. Store only its SHA-256 hash.
+Give each token one immutable scope:
+
+- one repository;
+- watched activity;
+- assignments;
+- mentions.
+
+A repository token also stores one repository ID. A personalized token has no
+repository ID. The database constraints enforce these combinations. The token
+URL is `/feeds/{token}/atom.xml` or `/feeds/{token}/rss.xml`. The URL does not
+accept a second scope value, so a token cannot request a different scope.
+
+Show a new token only in the response that creates or rotates it. The token
+list shows the stable token record ID, scope, target, time, and state, but it
+does not show a token or its hash. Rotation revokes the old record and creates
+the replacement in one immediate transaction. Revocation cannot be reversed.
+An account can have at most 32 active tokens. The management page returns at
+most 100 token records.
+
+Check the account state and current repository access for each feed read. A
+repository token returns only events from its repository. A watched feed
+applies the stored push, issue, and pull-request selections. An assignment feed
+selects assignment events for the token account. A mention feed selects exact
+`@username` references from issue and comment bodies. A bounded scan of at most
+1,000 recent issue events supplies at most 20 mention entries.
+
+Return private feeds with `Cache-Control: private, no-store`. All Web responses
+use `Referrer-Policy: no-referrer`. The HTTP layer does not log request URLs or
+form bodies, so it does not put a feed token in an application log.
+
+## Failure and threat cases
+
+An invalid, unknown, rotated, revoked, or suspended-account token gets the same
+not-found response. Token creation stops at the active-token limit. A public
+feed cannot read a private repository. A private feed query checks current
+visibility, ownership, and collaborator data, so a removed role cannot
+continue to return private events.
+
+The event queries sort by immutable repository sequence for one repository and
+by immutable creation time plus event ID for multi-repository feeds. An edit
+does not change these values. The queries have explicit limits.
+
+The token value appears in the one-time result and in the feed URL because the
+feed reader must send it. It does not appear in a later management page, an
+error page, a referrer, or the database.
+
+## Evidence
+
+Storage tests cover all four scopes, exact mention matching, issue-only event
+selection, access denial, hash lookup, rotation, revocation, and scope
+separation. The production HTTP test parses public issue feeds and each private
+feed format. It makes a repository private, proves that the public feed is
+hidden, reads it with its repository token, verifies hash-only storage, proves
+one-time display, rotates the token, and revokes the replacement.
+
+## Consequences
+
+Version 1 has feed delivery without an inbox or a background process. A person
+must protect a private feed URL as they protect a password. A later inbox can
+consume the same canonical events and current authorization data.
diff --git a/scripts/check-m4-4 b/scripts/check-m4-4
new file mode 100755
index 0000000000000000000000000000000000000000..1e5e6d6b38e5830f0fb4ed5102262e8258ceafa0
--- /dev/null
+++ b/scripts/check-m4-4
@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles
+cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
diff --git a/src/feed.rs b/src/feed.rs
index 561ba501b4e0c225f2d8ea6d1d4f71cc73b98f87..4a813d9911576782fc7adc7cbf86a8bf278320cc 100644
--- a/src/feed.rs
+++ b/src/feed.rs
@@ -3,13 +3,19 @@
 
 use thiserror::Error;
 
-use crate::store::{RepositoryEventRecord, RepositoryRecord};
+use crate::store::{ActivityEventRecord, RepositoryEventRecord, RepositoryRecord};
 
 pub(crate) const PAGE_SIZE: usize = 20;
 
 pub(crate) enum FeedFormat {
     Atom,
     Rss,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum RepositoryFeedKind {
+    Activity,
+    Issues,
 }
 
 pub(crate) struct FeedPage<'a> {
@@ -19,6 +25,7 @@
     pub(crate) self_url: &'a str,
     pub(crate) events: &'a [RepositoryEventRecord],
     pub(crate) next_before: Option<i64>,
+    pub(crate) kind: RepositoryFeedKind,
 }
 
 impl FeedPage<'_> {
@@ -103,11 +110,22 @@
     }
 
     fn feed_id(&self) -> String {
-        format!("urn:tit:repository:{}:events", self.repository.id)
+        let suffix = match self.kind {
+            RepositoryFeedKind::Activity => "events",
+            RepositoryFeedKind::Issues => "issues",
+        };
+        format!("urn:tit:repository:{}:{suffix}", self.repository.id)
     }
 
     fn feed_title(&self) -> String {
-        format!("{}/{} events", self.repository.owner, self.repository.slug)
+        let suffix = match self.kind {
+            RepositoryFeedKind::Activity => "events",
+            RepositoryFeedKind::Issues => "issues",
+        };
+        format!(
+            "{}/{} {suffix}",
+            self.repository.owner, self.repository.slug
+        )
     }
 
     fn repository_url(&self) -> String {
@@ -121,6 +139,126 @@
         self.next_before
             .map(|before| format!("{}?before={before}", self.feed_url))
     }
+}
+
+pub(crate) struct ActivityFeedPage<'a> {
+    pub(crate) base_url: &'a str,
+    pub(crate) self_url: &'a str,
+    pub(crate) scope: &'a str,
+    pub(crate) username: &'a str,
+    pub(crate) target: Option<&'a str>,
+    pub(crate) events: &'a [ActivityEventRecord],
+}
+
+impl ActivityFeedPage<'_> {
+    pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
+        match format {
+            FeedFormat::Atom => self.atom(),
+            FeedFormat::Rss => self.rss(),
+        }
+    }
+
+    fn atom(&self) -> Result<String, FeedError> {
+        let mut output = String::from(
+            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
+        );
+        element(&mut output, "id", &self.feed_id())?;
+        element(&mut output, "title", &self.feed_title())?;
+        element(&mut output, "updated", &atom_date(self.updated())?)?;
+        empty_link(&mut output, "self", self.self_url)?;
+        for record in self.events {
+            let link = activity_link(self.base_url, record);
+            output.push_str("<entry>\n");
+            element(&mut output, "id", &event_id(&record.event.event_id))?;
+            element(&mut output, "title", &activity_title(record))?;
+            element(&mut output, "updated", &atom_date(record.event.created_at)?)?;
+            empty_link(&mut output, "alternate", &link)?;
+            output.push_str("<author>");
+            element(&mut output, "name", &record.event.actor)?;
+            output.push_str("</author>\n<content type=\"text\">");
+            escape_xml(&event_description(&record.event), &mut output)?;
+            output.push_str("</content>\n</entry>\n");
+        }
+        output.push_str("</feed>\n");
+        Ok(output)
+    }
+
+    fn rss(&self) -> Result<String, FeedError> {
+        let mut output = String::from(
+            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n",
+        );
+        element(&mut output, "title", &self.feed_title())?;
+        element(&mut output, "link", self.base_url)?;
+        element(&mut output, "description", &self.feed_title())?;
+        element(&mut output, "lastBuildDate", &rss_date(self.updated())?)?;
+        atom_link(&mut output, "self", self.self_url)?;
+        for record in self.events {
+            let link = activity_link(self.base_url, record);
+            output.push_str("<item>\n");
+            element(&mut output, "title", &activity_title(record))?;
+            element(&mut output, "link", &link)?;
+            write!(output, "<guid isPermaLink=\"false\">")?;
+            escape_xml(&event_id(&record.event.event_id), &mut output)?;
+            output.push_str("</guid>\n");
+            element(&mut output, "pubDate", &rss_date(record.event.created_at)?)?;
+            element(
+                &mut output,
+                "description",
+                &event_description(&record.event),
+            )?;
+            output.push_str("</item>\n");
+        }
+        output.push_str("</channel>\n</rss>\n");
+        Ok(output)
+    }
+
+    fn feed_id(&self) -> String {
+        format!(
+            "urn:tit:account:{}:{}:{}",
+            self.username,
+            self.scope,
+            self.target.unwrap_or(self.username)
+        )
+    }
+
+    fn feed_title(&self) -> String {
+        let title = match self.scope {
+            "repository" => "repository activity",
+            "watched" => "watched activity",
+            "assignments" => "assignments",
+            "mentions" => "mentions",
+            _ => "activity",
+        };
+        format!("{} {title}", self.username)
+    }
+
+    fn updated(&self) -> i64 {
+        self.events
+            .iter()
+            .map(|record| record.event.created_at)
+            .max()
+            .unwrap_or(0)
+    }
+}
+
+fn activity_title(record: &ActivityEventRecord) -> String {
+    format!(
+        "{}/{}: {}",
+        record.repository.owner,
+        record.repository.slug,
+        event_title(&record.event)
+    )
+}
+
+fn activity_link(base_url: &str, record: &ActivityEventRecord) -> String {
+    let repository_url = format!(
+        "{}/{}/{}",
+        base_url, record.repository.owner, record.repository.slug
+    );
+    let number = issue_payload(&record.event).and_then(|payload| payload.get("number")?.as_i64());
+    number.map_or(repository_url.clone(), |number| {
+        format!("{repository_url}/issues/{number}")
+    })
 }
 
 fn event_id(event_id: &str) -> String {
diff --git a/src/feed_token.rs b/src/feed_token.rs
new file mode 100644
index 0000000000000000000000000000000000000000..cfdbe5eb7126b02149099c8bd8deb6abe47b551b
--- /dev/null
+++ b/src/feed_token.rs
@@ -1,0 +1,151 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::store::{FeedTokenRecord, Store, StoreError, TokenFeedPage};
+
+const TOKEN_BYTES: usize = 32;
+
+#[derive(Clone)]
+pub(crate) struct FeedTokenService {
+    database: PathBuf,
+}
+
+impl FeedTokenService {
+    pub(crate) fn new(database: &Path) -> Self {
+        Self {
+            database: database.to_owned(),
+        }
+    }
+
+    pub(crate) fn list(&self, actor: &str) -> Result<Vec<FeedTokenRecord>, FeedTokenError> {
+        validate_username(actor)?;
+        Store::open(&self.database)?
+            .feed_tokens(actor)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn issue(
+        &self,
+        actor: &str,
+        scope: &str,
+        owner: Option<&str>,
+        repository: Option<&str>,
+    ) -> Result<IssuedFeedToken, FeedTokenError> {
+        validate_username(actor)?;
+        let target = match (scope, owner, repository) {
+            ("repository", Some(owner), Some(repository)) => {
+                validate_username(owner)?;
+                validate_slug(repository)?;
+                Some((owner, repository))
+            }
+            ("watched" | "assignments" | "mentions", None, None) => None,
+            _ => return Err(FeedTokenError::InvalidScope),
+        };
+        let token = random_token()?;
+        let record = Store::open(&self.database)?.create_feed_token(
+            actor,
+            scope,
+            target,
+            &hash(&token),
+            now()?,
+        )?;
+        Ok(IssuedFeedToken { record, token })
+    }
+
+    pub(crate) fn rotate(&self, actor: &str, id: &str) -> Result<IssuedFeedToken, FeedTokenError> {
+        validate_username(actor)?;
+        validate_id(id)?;
+        let token = random_token()?;
+        let record =
+            Store::open(&self.database)?.rotate_feed_token(actor, id, &hash(&token), now()?)?;
+        Ok(IssuedFeedToken { record, token })
+    }
+
+    pub(crate) fn revoke(&self, actor: &str, id: &str) -> Result<(), FeedTokenError> {
+        validate_username(actor)?;
+        validate_id(id)?;
+        Store::open(&self.database)?
+            .revoke_feed_token(actor, id, now()?)
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn read(&self, token: &str, limit: usize) -> Result<TokenFeedPage, FeedTokenError> {
+        validate_token(token)?;
+        Store::open(&self.database)?
+            .token_feed_events(&hash(token), limit)
+            .map_err(Into::into)
+    }
+}
+
+pub(crate) struct IssuedFeedToken {
+    pub(crate) record: FeedTokenRecord,
+    pub(crate) token: String,
+}
+
+fn random_token() -> Result<String, FeedTokenError> {
+    let mut bytes = [0_u8; TOKEN_BYTES];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| FeedTokenError::Random)?;
+    Ok(encode_hex(&bytes))
+}
+
+fn hash(token: &str) -> [u8; 32] {
+    Sha256::digest(token.as_bytes()).into()
+}
+
+fn validate_token(token: &str) -> Result<(), FeedTokenError> {
+    if token.len() != TOKEN_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(FeedTokenError::InvalidToken);
+    }
+    Ok(())
+}
+
+fn validate_id(id: &str) -> Result<(), FeedTokenError> {
+    if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(FeedTokenError::InvalidToken);
+    }
+    Ok(())
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+    let mut output = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        use std::fmt::Write as _;
+        write!(output, "{byte:02x}").expect("writing to a string cannot fail");
+    }
+    output
+}
+
+fn now() -> Result<i64, FeedTokenError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| FeedTokenError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| FeedTokenError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum FeedTokenError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    RepositoryName(#[from] RepositoryNameError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("feed token scope is not valid")]
+    InvalidScope,
+    #[error("feed token is not valid")]
+    InvalidToken,
+    #[error("cannot create random feed token data")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+}
diff --git a/src/http/feeds.rs b/src/http/feeds.rs
new file mode 100644
index 0000000000000000000000000000000000000000..dbec8adff1c2715860c47bcef49b5fe9e21543b9
--- /dev/null
+++ b/src/http/feeds.rs
@@ -1,0 +1,368 @@
+use askama::Template;
+use axum::Router;
+use axum::body::Bytes;
+use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use serde::Deserialize;
+
+use crate::feed::{ActivityFeedPage, FeedFormat, PAGE_SIZE};
+use crate::feed_token::{FeedTokenError, IssuedFeedToken};
+use crate::store::{FeedTokenRecord, StoreError};
+
+use super::public::conditional_feed;
+use super::{
+    CSRF_COOKIE, RequestId, SESSION_COOKIE, WebState, authenticate_mutation, cookie, login_job,
+    login_redirect, parse_named_form, render, render_error,
+};
+
+pub(super) fn routes() -> Router<WebState> {
+    Router::new()
+        .route("/feeds", get(feed_tokens))
+        .route(
+            "/feeds/tokens",
+            post(issue_token).layer(DefaultBodyLimit::max(4096)),
+        )
+        .route(
+            "/feeds/tokens/{id}/rotate",
+            post(rotate_token).layer(DefaultBodyLimit::max(1024)),
+        )
+        .route(
+            "/feeds/tokens/{id}/revoke",
+            post(revoke_token).layer(DefaultBodyLimit::max(1024)),
+        )
+        .route("/feeds/{token}/atom.xml", get(atom_feed))
+        .route("/feeds/{token}/rss.xml", get(rss_feed))
+}
+
+async fn feed_tokens(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+        return login_redirect(true);
+    };
+    let csrf_for_auth = csrf.clone();
+    let actor = match login_job(state.clone(), move |login| {
+        login.authenticate(&session_token, Some(&csrf_for_auth))
+    })
+    .await
+    {
+        Ok(session) => session.username,
+        Err(_) => return login_redirect(true),
+    };
+    let Some(service) = state.feeds.clone() else {
+        return feed_internal(&request_id.0);
+    };
+    let result = feed_job(state, move || service.list(&actor)).await;
+    match result {
+        Ok(tokens) => render(
+            StatusCode::OK,
+            &FeedTokensTemplate {
+                request_id: &request_id.0,
+                csrf: &csrf,
+                tokens: tokens.iter().map(token_view).collect(),
+            },
+        ),
+        Err(_) => feed_internal(&request_id.0),
+    }
+}
+
+async fn issue_token(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf", "scope", "owner", "repository"])
+    {
+        Ok(fields) => fields,
+        Err(()) => return feed_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.feeds.clone() else {
+        return feed_internal(&request_id.0);
+    };
+    let scope = fields[1].clone();
+    let owner = nonempty(fields[2].clone());
+    let repository = nonempty(fields[3].clone());
+    let result = feed_job(state, move || {
+        service.issue(&actor, &scope, owner.as_deref(), repository.as_deref())
+    })
+    .await;
+    issued_response(result, &request_id.0)
+}
+
+async fn rotate_token(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<TokenIdPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => return feed_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.feeds.clone() else {
+        return feed_internal(&request_id.0);
+    };
+    let result = feed_job(state, move || service.rotate(&actor, &path.id)).await;
+    issued_response(result, &request_id.0)
+}
+
+async fn revoke_token(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<TokenIdPath>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => return feed_bad_request(&request_id.0),
+    };
+    let actor =
+        match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+            Ok(actor) => actor,
+            Err(response) => return response,
+        };
+    let Some(service) = state.feeds.clone() else {
+        return feed_internal(&request_id.0);
+    };
+    let result = feed_job(state, move || service.revoke(&actor, &path.id)).await;
+    match result {
+        Ok(()) => Response::builder()
+            .status(StatusCode::SEE_OTHER)
+            .header(header::LOCATION, "/feeds")
+            .header(header::CACHE_CONTROL, "no-store")
+            .body(axum::body::Body::empty())
+            .expect("the feed token redirect is valid"),
+        Err(error) => feed_management_error(error, &request_id.0),
+    }
+}
+
+async fn atom_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<TokenPath>,
+    headers: HeaderMap,
+) -> Response {
+    token_feed(state, request_id, path.token, headers, FeedFormat::Atom).await
+}
+
+async fn rss_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Path(path): Path<TokenPath>,
+    headers: HeaderMap,
+) -> Response {
+    token_feed(state, request_id, path.token, headers, FeedFormat::Rss).await
+}
+
+async fn token_feed(
+    state: WebState,
+    request_id: RequestId,
+    token: String,
+    headers: HeaderMap,
+    format: FeedFormat,
+) -> Response {
+    let Some(service) = state.feeds.clone() else {
+        return feed_not_found(&request_id.0);
+    };
+    let Some(public) = state.public.clone() else {
+        return feed_not_found(&request_id.0);
+    };
+    let base_url = public.http_clone_base().to_owned();
+    let token_for_url = token.clone();
+    let result = feed_job(state, move || service.read(&token, PAGE_SIZE)).await;
+    let page = match result {
+        Ok(page) => page,
+        Err(_) => return feed_not_found(&request_id.0),
+    };
+    let name = match format {
+        FeedFormat::Atom => "atom.xml",
+        FeedFormat::Rss => "rss.xml",
+    };
+    let self_url = format!("{base_url}/feeds/{token_for_url}/{name}");
+    let newest = page
+        .events
+        .iter()
+        .map(|record| record.event.created_at)
+        .max()
+        .unwrap_or(0);
+    let body = match (ActivityFeedPage {
+        base_url: &base_url,
+        self_url: &self_url,
+        scope: &page.scope,
+        username: &page.username,
+        target: page.target.as_deref(),
+        events: &page.events,
+    })
+    .render(format)
+    {
+        Ok(body) => body,
+        Err(_) => return feed_internal(&request_id.0),
+    };
+    conditional_feed(&headers, name, body, newest, false)
+}
+
+async fn feed_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce() -> Result<T, FeedTokenError> + Send + 'static,
+) -> Result<T, FeedTokenError> {
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        FeedTokenError::Store(StoreError::Integrity(
+            "feed worker pool is unavailable".to_owned(),
+        ))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation()
+    })
+    .await
+    .map_err(|_| FeedTokenError::Store(StoreError::Integrity("feed worker failed".to_owned())))?
+}
+
+fn issued_response(result: Result<IssuedFeedToken, FeedTokenError>, request_id: &str) -> Response {
+    match result {
+        Ok(issued) => render(
+            StatusCode::CREATED,
+            &IssuedFeedTokenTemplate {
+                request_id,
+                token: &issued.token,
+                scope: scope_label(&issued.record.scope),
+                target: token_target(&issued.record),
+            },
+        ),
+        Err(error) => feed_management_error(error, request_id),
+    }
+}
+
+fn token_view(record: &FeedTokenRecord) -> FeedTokenView<'_> {
+    FeedTokenView {
+        id: &record.id,
+        scope: scope_label(&record.scope),
+        target: token_target(record),
+        created_at: record.created_at,
+        active: record.revoked_at.is_none(),
+    }
+}
+
+fn token_target(record: &FeedTokenRecord) -> String {
+    match (&record.owner, &record.repository) {
+        (Some(owner), Some(repository)) => format!("{owner}/{repository}"),
+        _ => "Your account".to_owned(),
+    }
+}
+
+fn scope_label(scope: &str) -> &'static str {
+    match scope {
+        "repository" => "Repository activity",
+        "watched" => "Watched activity",
+        "assignments" => "Assignments",
+        "mentions" => "Mentions",
+        _ => "Unknown",
+    }
+}
+
+fn nonempty(value: String) -> Option<String> {
+    (!value.is_empty()).then_some(value)
+}
+
+fn feed_management_error(error: FeedTokenError, request_id: &str) -> Response {
+    match error {
+        FeedTokenError::InvalidScope
+        | FeedTokenError::InvalidToken
+        | FeedTokenError::Auth(_)
+        | FeedTokenError::RepositoryName(_) => feed_bad_request(request_id),
+        FeedTokenError::Store(
+            StoreError::FeedTokenDenied
+            | StoreError::FeedTokenNotFound
+            | StoreError::RepositoryNotFound(_, _),
+        ) => feed_not_found(request_id),
+        FeedTokenError::Store(StoreError::FeedTokenLimit) => render_error(
+            StatusCode::TOO_MANY_REQUESTS,
+            request_id,
+            "Feed token limit",
+            "Revoke an active feed token before you create another token.",
+        ),
+        _ => feed_internal(request_id),
+    }
+}
+
+fn feed_bad_request(request_id: &str) -> Response {
+    render_error(
+        StatusCode::BAD_REQUEST,
+        request_id,
+        "Feed token error",
+        "The feed token request is not valid.",
+    )
+}
+
+fn feed_not_found(request_id: &str) -> Response {
+    render_error(
+        StatusCode::NOT_FOUND,
+        request_id,
+        "Feed not found",
+        "The feed does not exist.",
+    )
+}
+
+fn feed_internal(request_id: &str) -> Response {
+    render_error(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        request_id,
+        "Feed error",
+        "The feed request could not be completed.",
+    )
+}
+
+#[derive(Deserialize)]
+struct TokenPath {
+    token: String,
+}
+
+#[derive(Deserialize)]
+struct TokenIdPath {
+    id: String,
+}
+
+struct FeedTokenView<'a> {
+    id: &'a str,
+    scope: &'static str,
+    target: String,
+    created_at: i64,
+    active: bool,
+}
+
+#[derive(Template)]
+#[template(path = "feed-tokens.html")]
+struct FeedTokensTemplate<'a> {
+    request_id: &'a str,
+    csrf: &'a str,
+    tokens: Vec<FeedTokenView<'a>>,
+}
+
+#[derive(Template)]
+#[template(path = "feed-token-issued.html")]
+struct IssuedFeedTokenTemplate<'a> {
+    request_id: &'a str,
+    token: &'a str,
+    scope: &'static str,
+    target: String,
+}
diff --git a/src/http/mod.rs b/src/http/mod.rs
index b9cd9fa09d89f04bf76df8b371d6ba413059409e..e4e965678e3aeb2e239503f9de0171d186e65594 100644
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -1,3 +1,4 @@
+mod feeds;
 mod issues;
 mod public;
 mod watches;
@@ -22,6 +23,7 @@
 use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::feed_token::FeedTokenService;
 use crate::issue::IssueService;
 use crate::repository::{RepositoryService, RepositoryServiceError};
 use crate::session::{SessionError, WebLoginService};
@@ -47,6 +49,7 @@
     login: Option<WebLoginService>,
     repositories: Option<RepositoryService>,
     issues: Option<IssueService>,
+    feeds: Option<FeedTokenService>,
     watches: Option<WatchService>,
     secure_cookies: bool,
 }
@@ -81,6 +84,7 @@
                 login: None,
                 repositories: None,
                 issues: None,
+                feeds: None,
                 watches: None,
                 secure_cookies: false,
             },
@@ -118,6 +122,7 @@
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         let repositories = RepositoryService::new(public.database(), public.repository_root());
         let issues = IssueService::new(public.database());
+        let feeds = FeedTokenService::new(public.database());
         let watches = WatchService::new(public.database());
         Self::start_with_state(
             address,
@@ -129,6 +134,7 @@
                 login: Some(login),
                 repositories: Some(repositories),
                 issues: Some(issues),
+                feeds: Some(feeds),
                 watches: Some(watches),
                 secure_cookies,
             },
@@ -174,6 +180,7 @@
         login: None,
         repositories: None,
         issues: None,
+        feeds: None,
         watches: None,
         secure_cookies: false,
     })
@@ -226,6 +233,7 @@
             axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
         )
         .route("/assets/style.css", get(style))
+        .merge(feeds::routes())
         .merge(repository_routes)
         .fallback(not_found)
         .method_not_allowed_fallback(method_not_allowed)
diff --git a/src/http/public.rs b/src/http/public.rs
index dacddf00f825b88e7b6c4bf0f5976425ac895145..795cd40715cc1da364e3ea495e9e2accd733197f 100644
--- a/src/http/public.rs
+++ b/src/http/public.rs
@@ -22,7 +22,7 @@
 
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
-use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE};
+use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE, RepositoryFeedKind};
 use crate::git::packetline::MAX_REQUEST_BYTES;
 use crate::git::read::{
     BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
@@ -82,6 +82,10 @@
         &self.repositories
     }
 
+    pub(super) fn http_clone_base(&self) -> &str {
+        &self.http_clone_base
+    }
+
     async fn read<T, F>(
         &self,
         actor: Option<String>,
@@ -123,6 +127,7 @@
         owner: String,
         repository: String,
         before: Option<i64>,
+        kind: RepositoryFeedKind,
     ) -> Result<(RepositoryRecord, Vec<crate::store::RepositoryEventRecord>), RouteError> {
         validate_username(&owner).map_err(|_| RouteError::NotFound)?;
         validate_slug(&repository).map_err(|_| RouteError::NotFound)?;
@@ -142,9 +147,16 @@
                 &repository,
                 RepositoryOperation::Read,
             )?;
-            Store::open(&database)?
-                .repository_events(&owner, &repository, before, PAGE_SIZE + 1)
-                .map_err(Into::into)
+            let store = Store::open(&database)?;
+            match kind {
+                RepositoryFeedKind::Activity => {
+                    store.repository_events(&owner, &repository, before, PAGE_SIZE + 1)
+                }
+                RepositoryFeedKind::Issues => {
+                    store.repository_issue_events(&owner, &repository, before, PAGE_SIZE + 1)
+                }
+            }
+            .map_err(Into::into)
         })
         .await
         .map_err(|_| RouteError::Internal)?
@@ -254,6 +266,11 @@
         .route("/{owner}/{repository}/refs", get(refs))
         .route("/{owner}/{repository}/atom.xml", get(atom_feed))
         .route("/{owner}/{repository}/rss.xml", get(rss_feed))
+        .route(
+            "/{owner}/{repository}/issues/atom.xml",
+            get(issue_atom_feed),
+        )
+        .route("/{owner}/{repository}/issues/rss.xml", get(issue_rss_feed))
         .route("/{owner}/{repository}/search", get(search))
         .route("/{owner}/{repository}/commit/{commit}", get(commit))
         .route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
@@ -281,7 +298,7 @@
         path,
         query,
         headers,
-        FeedFormat::Atom,
+        (FeedFormat::Atom, RepositoryFeedKind::Activity),
     )
     .await
 }
@@ -301,7 +318,47 @@
         path,
         query,
         headers,
-        FeedFormat::Rss,
+        (FeedFormat::Rss, RepositoryFeedKind::Activity),
+    )
+    .await
+}
+
+async fn issue_atom_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<FeedQuery>,
+    headers: HeaderMap,
+) -> Response {
+    feed_response(
+        state,
+        request_id,
+        actor,
+        path,
+        query,
+        headers,
+        (FeedFormat::Atom, RepositoryFeedKind::Issues),
+    )
+    .await
+}
+
+async fn issue_rss_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    Extension(actor): Extension<RequestActor>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<FeedQuery>,
+    headers: HeaderMap,
+) -> Response {
+    feed_response(
+        state,
+        request_id,
+        actor,
+        path,
+        query,
+        headers,
+        (FeedFormat::Rss, RepositoryFeedKind::Issues),
     )
     .await
 }
@@ -313,8 +370,9 @@
     path: RepositoryPath,
     query: FeedQuery,
     headers: HeaderMap,
-    format: FeedFormat,
+    feed: (FeedFormat, RepositoryFeedKind),
 ) -> Response {
+    let (format, kind) = feed;
     if matches!(query.before, Some(before) if before <= 0) {
         return route_error(RouteError::InvalidRequest, &request_id.0);
     }
@@ -324,7 +382,13 @@
     let owner = path.owner;
     let repository = path.repository;
     let (record, mut events) = match web
-        .event_page(actor.0, owner.clone(), repository.clone(), query.before)
+        .event_page(
+            actor.0,
+            owner.clone(),
+            repository.clone(),
+            query.before,
+            kind,
+        )
         .await
     {
         Ok(page) => page,
@@ -356,6 +420,7 @@
         self_url: &self_url,
         events: &events,
         next_before,
+        kind,
     })
     .render(format)
     {
@@ -849,7 +914,7 @@
     }
 }
 
-fn conditional_feed(
+pub(super) fn conditional_feed(
     headers: &HeaderMap,
     name: &str,
     body: String,
diff --git a/src/main.rs b/src/main.rs
index 741d505fcaf8a3fbae0f8f02f754640d867e8f1f..ba1d87e7422f277bf3573637046633b20cae7b96 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,6 +11,7 @@
 mod control;
 mod domain;
 mod feed;
+mod feed_token;
 #[allow(dead_code, reason = "the server uses only part of the shared Git API")]
 mod git;
 #[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
diff --git a/src/store/migrations/014_feed_tokens.sql b/src/store/migrations/014_feed_tokens.sql
new file mode 100644
index 0000000000000000000000000000000000000000..2c2c5bc4dc74c829b48e9dd897957bebd4d588c3
--- /dev/null
+++ b/src/store/migrations/014_feed_tokens.sql
@@ -1,0 +1,28 @@
+CREATE TABLE feed_token (
+    id TEXT PRIMARY KEY
+        CHECK (
+            length(id) = 32
+            AND id = lower(id)
+            AND id NOT GLOB '*[^0-9a-f]*'
+        ),
+    token_hash BLOB NOT NULL UNIQUE CHECK (length(token_hash) = 32),
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    scope TEXT NOT NULL
+        CHECK (scope IN ('repository', 'watched', 'assignments', 'mentions')),
+    repository_id TEXT
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    revoked_at INTEGER CHECK (revoked_at IS NULL OR revoked_at >= created_at),
+    CHECK (
+        (scope = 'repository' AND repository_id IS NOT NULL)
+        OR (scope != 'repository' AND repository_id IS NULL)
+    )
+) STRICT;
+
+CREATE INDEX feed_token_account_active
+ON feed_token (account_id, revoked_at, created_at DESC);
+
+CREATE INDEX feed_token_repository_active
+ON feed_token (repository_id, revoked_at)
+WHERE repository_id IS NOT NULL;
diff --git a/src/store/mod.rs b/src/store/mod.rs
index 7a293352bc520a9bd90de97f5b0ac82beec455e1..f951548112789d6c920e4cdaa587c02ee476402b 100644
--- a/src/store/mod.rs
+++ b/src/store/mod.rs
@@ -11,7 +11,8 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 13;
+const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
+const SCHEMA_VERSION: i64 = 14;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -21,7 +22,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 13] = [
+const MIGRATIONS: [&str; 14] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -35,6 +36,7 @@
     include_str!("migrations/011_domain_events.sql"),
     include_str!("migrations/012_issues.sql"),
     include_str!("migrations/013_watches.sql"),
+    include_str!("migrations/014_feed_tokens.sql"),
 ];
 
 #[allow(
@@ -133,6 +135,14 @@
     IssueAssigneeNotFound(String),
     #[error("repository watch access is not authorized")]
     WatchDenied,
+    #[error("feed token access is not authorized")]
+    FeedTokenDenied,
+    #[error("feed token is invalid or revoked")]
+    FeedTokenNotFound,
+    #[error("an account cannot have more than 32 active feed tokens")]
+    FeedTokenLimit,
+    #[error("feed token scope is not valid")]
+    InvalidFeedScope,
 }
 
 pub(crate) struct Store {
@@ -1938,6 +1948,198 @@
         Ok(Some(record))
     }
 
+    pub(crate) fn create_feed_token(
+        &mut self,
+        actor: &str,
+        scope: &str,
+        repository: Option<(&str, &str)>,
+        token_hash: &[u8; 32],
+        created_at: i64,
+    ) -> Result<FeedTokenRecord, StoreError> {
+        validate_feed_scope(scope, repository)?;
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, actor)?;
+        let active_tokens: i64 = transaction.query_row(
+            "SELECT count(*) FROM feed_token
+             WHERE account_id = ?1 AND revoked_at IS NULL",
+            [account_id],
+            |row| row.get(0),
+        )?;
+        if active_tokens >= MAX_ACTIVE_FEED_TOKENS {
+            return Err(StoreError::FeedTokenLimit);
+        }
+        let repository_id = match repository {
+            Some((owner, repository)) => {
+                let access = repository_issue_access(&transaction, owner, repository, Some(actor))?;
+                if !access.can_read() {
+                    return Err(StoreError::FeedTokenDenied);
+                }
+                Some(access.repository.id)
+            }
+            None => None,
+        };
+        transaction.execute(
+            "INSERT INTO feed_token
+             (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
+             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
+            rusqlite::params![
+                token_hash.as_slice(),
+                account_id,
+                scope,
+                repository_id,
+                created_at
+            ],
+        )?;
+        let record = transaction.query_row(
+            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+                    feed_token.created_at, feed_token.revoked_at
+             FROM feed_token
+             LEFT JOIN repository ON repository.id = feed_token.repository_id
+             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+             WHERE feed_token.rowid = last_insert_rowid()",
+            [],
+            feed_token_from_row,
+        )?;
+        transaction.commit()?;
+        Ok(record)
+    }
+
+    pub(crate) fn feed_tokens(&self, actor: &str) -> Result<Vec<FeedTokenRecord>, StoreError> {
+        let account_id = active_account_id(&self.connection, actor)?;
+        let mut statement = self.connection.prepare(
+            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+                    feed_token.created_at, feed_token.revoked_at
+             FROM feed_token
+             LEFT JOIN repository ON repository.id = feed_token.repository_id
+             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+             WHERE feed_token.account_id = ?1
+             ORDER BY feed_token.created_at DESC, feed_token.id
+             LIMIT 100",
+        )?;
+        statement
+            .query_map([account_id], feed_token_from_row)?
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn rotate_feed_token(
+        &mut self,
+        actor: &str,
+        id: &str,
+        token_hash: &[u8; 32],
+        changed_at: i64,
+    ) -> Result<FeedTokenRecord, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, actor)?;
+        let current = transaction
+            .query_row(
+                "SELECT scope, repository_id FROM feed_token
+                 WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+                rusqlite::params![id, account_id],
+                |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
+            )
+            .optional()?
+            .ok_or(StoreError::FeedTokenNotFound)?;
+        transaction.execute(
+            "UPDATE feed_token SET revoked_at = ?3
+             WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+            rusqlite::params![id, account_id, changed_at],
+        )?;
+        transaction.execute(
+            "INSERT INTO feed_token
+             (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
+             VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
+            rusqlite::params![
+                token_hash.as_slice(),
+                account_id,
+                current.0,
+                current.1,
+                changed_at
+            ],
+        )?;
+        let record = transaction.query_row(
+            "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+                    feed_token.created_at, feed_token.revoked_at
+             FROM feed_token
+             LEFT JOIN repository ON repository.id = feed_token.repository_id
+             LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+             WHERE feed_token.rowid = last_insert_rowid()",
+            [],
+            feed_token_from_row,
+        )?;
+        transaction.commit()?;
+        Ok(record)
+    }
+
+    pub(crate) fn revoke_feed_token(
+        &mut self,
+        actor: &str,
+        id: &str,
+        revoked_at: i64,
+    ) -> Result<(), StoreError> {
+        let account_id = active_account_id(&self.connection, actor)?;
+        let changed = self.connection.execute(
+            "UPDATE feed_token SET revoked_at = ?3
+             WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+            rusqlite::params![id, account_id, revoked_at],
+        )?;
+        if changed == 0 {
+            return Err(StoreError::FeedTokenNotFound);
+        }
+        Ok(())
+    }
+
+    pub(crate) fn token_feed_events(
+        &self,
+        token_hash: &[u8; 32],
+        limit: usize,
+    ) -> Result<TokenFeedPage, StoreError> {
+        let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+        let grant = self
+            .connection
+            .query_row(
+                "SELECT feed_token.scope, feed_token.repository_id,
+                        account.id, account.username
+                 FROM feed_token
+                 JOIN account ON account.id = feed_token.account_id
+                 WHERE feed_token.token_hash = ?1 AND feed_token.revoked_at IS NULL
+                   AND account.state = 'active'",
+                [token_hash.as_slice()],
+                |row| {
+                    Ok((
+                        row.get::<_, String>(0)?,
+                        row.get::<_, Option<String>>(1)?,
+                        row.get::<_, i64>(2)?,
+                        row.get::<_, String>(3)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or(StoreError::FeedTokenNotFound)?;
+        let events = match grant.0.as_str() {
+            "repository" => token_repository_events(
+                &self.connection,
+                grant.1.as_deref().ok_or(StoreError::InvalidFeedScope)?,
+                grant.2,
+                limit,
+            )?,
+            "watched" => watched_feed_events(&self.connection, grant.2, limit)?,
+            "assignments" => assignment_feed_events(&self.connection, grant.2, &grant.3, limit)?,
+            "mentions" => mention_feed_events(&self.connection, grant.2, &grant.3, limit)?,
+            _ => return Err(StoreError::InvalidFeedScope),
+        };
+        Ok(TokenFeedPage {
+            scope: grant.0,
+            username: grant.3,
+            target: grant.1,
+            events,
+        })
+    }
+
     #[allow(
         dead_code,
         reason = "some integration tests compile storage without authorization"
@@ -2063,7 +2265,7 @@
         limit: usize,
     ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
         let repository = self.public_repository(owner, slug)?;
-        self.repository_events_for(repository, before, limit)
+        self.repository_events_for(repository, before, limit, false)
     }
 
     #[allow(
@@ -2078,7 +2280,18 @@
         limit: usize,
     ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
         let repository = self.repository(owner, slug)?;
-        self.repository_events_for(repository, before, limit)
+        self.repository_events_for(repository, before, limit, false)
+    }
+
+    pub(crate) fn repository_issue_events(
+        &self,
+        owner: &str,
+        slug: &str,
+        before: Option<i64>,
+        limit: usize,
+    ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
+        let repository = self.repository(owner, slug)?;
+        self.repository_events_for(repository, before, limit, true)
     }
 
     fn repository_events_for(
@@ -2086,6 +2299,7 @@
         repository: RepositoryRecord,
         before: Option<i64>,
         limit: usize,
+        issues_only: bool,
     ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
         let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
         let mut statement = self.connection.prepare(
@@ -2093,24 +2307,28 @@
                     payload_version, payload, created_at
              FROM repository_event
              WHERE repository_id = ?1 AND (?2 IS NULL OR sequence < ?2)
+               AND (?4 = 0 OR kind LIKE 'issue-%')
              ORDER BY sequence DESC
              LIMIT ?3",
         )?;
         let events = statement
-            .query_map(rusqlite::params![repository.id, before, limit], |row| {
-                Ok(RepositoryEventRecord {
-                    event_id: row.get(0)?,
-                    sequence: row.get(1)?,
-                    kind: row.get(2)?,
-                    actor: row.get(3)?,
-                    ref_name: row.get(4)?,
-                    old_target: row.get(5)?,
-                    new_target: row.get(6)?,
-                    payload_version: row.get(7)?,
-                    payload: row.get(8)?,
-                    created_at: row.get(9)?,
-                })
-            })?
+            .query_map(
+                rusqlite::params![repository.id, before, limit, issues_only],
+                |row| {
+                    Ok(RepositoryEventRecord {
+                        event_id: row.get(0)?,
+                        sequence: row.get(1)?,
+                        kind: row.get(2)?,
+                        actor: row.get(3)?,
+                        ref_name: row.get(4)?,
+                        old_target: row.get(5)?,
+                        new_target: row.get(6)?,
+                        payload_version: row.get(7)?,
+                        payload: row.get(8)?,
+                        created_at: row.get(9)?,
+                    })
+                },
+            )?
             .collect::<Result<Vec<_>, _>>()?;
         Ok((repository, events))
     }
@@ -2372,6 +2590,28 @@
     pub(crate) updated_at: i64,
 }
 
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct FeedTokenRecord {
+    pub(crate) id: String,
+    pub(crate) scope: String,
+    pub(crate) owner: Option<String>,
+    pub(crate) repository: Option<String>,
+    pub(crate) created_at: i64,
+    pub(crate) revoked_at: Option<i64>,
+}
+
+pub(crate) struct ActivityEventRecord {
+    pub(crate) repository: RepositoryRecord,
+    pub(crate) event: RepositoryEventRecord,
+}
+
+pub(crate) struct TokenFeedPage {
+    pub(crate) scope: String,
+    pub(crate) username: String,
+    pub(crate) target: Option<String>,
+    pub(crate) events: Vec<ActivityEventRecord>,
+}
+
 pub(crate) struct GitOperationIntent<'a> {
     pub(crate) id: &'a str,
     pub(crate) repository_path: &'a str,
@@ -2542,6 +2782,211 @@
         )),
         Err(error) => Err(error.into()),
     }
+}
+
+fn validate_feed_scope(scope: &str, repository: Option<(&str, &str)>) -> Result<(), StoreError> {
+    match (scope, repository) {
+        ("repository", Some(_)) | ("watched" | "assignments" | "mentions", None) => Ok(()),
+        _ => Err(StoreError::InvalidFeedScope),
+    }
+}
+
+fn feed_token_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FeedTokenRecord> {
+    Ok(FeedTokenRecord {
+        id: row.get(0)?,
+        scope: row.get(1)?,
+        owner: row.get(2)?,
+        repository: row.get(3)?,
+        created_at: row.get(4)?,
+        revoked_at: row.get(5)?,
+    })
+}
+
+fn activity_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActivityEventRecord> {
+    Ok(ActivityEventRecord {
+        repository: RepositoryRecord {
+            id: row.get(0)?,
+            owner: row.get(1)?,
+            slug: row.get(2)?,
+            visibility: row.get(3)?,
+            state: row.get(4)?,
+            object_format: row.get(5)?,
+            created_at: row.get(6)?,
+            archived_at: row.get(7)?,
+        },
+        event: RepositoryEventRecord {
+            event_id: row.get(8)?,
+            sequence: row.get(9)?,
+            kind: row.get(10)?,
+            actor: row.get(11)?,
+            ref_name: row.get(12)?,
+            old_target: row.get(13)?,
+            new_target: row.get(14)?,
+            payload_version: row.get(15)?,
+            payload: row.get(16)?,
+            created_at: row.get(17)?,
+        },
+    })
+}
+
+const ACTIVITY_SELECT: &str = "SELECT repository.id, owner.username, repository.slug,
+            repository.visibility, repository.state, repository.object_format,
+            repository.created_at, repository.archived_at,
+            repository_event.event_id, repository_event.sequence,
+            repository_event.kind, repository_event.actor, repository_event.ref_name,
+            repository_event.old_target, repository_event.new_target,
+            repository_event.payload_version, repository_event.payload,
+            repository_event.created_at
+     FROM repository_event
+     JOIN repository ON repository.id = repository_event.repository_id
+     JOIN account AS owner ON owner.id = repository.owner_account_id";
+
+fn token_repository_events(
+    connection: &Connection,
+    repository_id: &str,
+    account_id: i64,
+    limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+    let sql = format!(
+        "{ACTIVITY_SELECT}
+         WHERE repository.id = ?1 AND repository.state = 'active'
+           AND (repository.visibility = 'public'
+                OR repository.owner_account_id = ?2
+                OR EXISTS (
+                    SELECT 1 FROM repository_collaborator
+                    WHERE repository_collaborator.repository_id = repository.id
+                      AND repository_collaborator.account_id = ?2
+                ))
+         ORDER BY repository_event.sequence DESC LIMIT ?3"
+    );
+    let mut statement = connection.prepare(&sql)?;
+    statement
+        .query_map(
+            rusqlite::params![repository_id, account_id, limit],
+            activity_from_row,
+        )?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(Into::into)
+}
+
+fn watched_feed_events(
+    connection: &Connection,
+    account_id: i64,
+    limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+    let sql = format!(
+        "{ACTIVITY_SELECT}
+         JOIN watch
+           ON watch.repository_id = repository.id AND watch.account_id = ?1
+         WHERE repository.state = 'active'
+           AND (repository.visibility = 'public'
+                OR repository.owner_account_id = ?1
+                OR EXISTS (
+                    SELECT 1 FROM repository_collaborator
+                    WHERE repository_collaborator.repository_id = repository.id
+                      AND repository_collaborator.account_id = ?1
+                ))
+           AND ((watch.pushes = 1 AND repository_event.kind IN
+                    ('push', 'ref-created', 'ref-updated', 'ref-deleted',
+                     'tag-created', 'tag-updated', 'tag-deleted'))
+                OR (watch.issues = 1 AND repository_event.kind LIKE 'issue-%')
+                OR (watch.pull_requests = 1
+                    AND repository_event.kind LIKE 'pull-request-%'))
+         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+         LIMIT ?2"
+    );
+    let mut statement = connection.prepare(&sql)?;
+    statement
+        .query_map(rusqlite::params![account_id, limit], activity_from_row)?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(Into::into)
+}
+
+fn assignment_feed_events(
+    connection: &Connection,
+    account_id: i64,
+    username: &str,
+    limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+    let sql = format!(
+        "{ACTIVITY_SELECT}
+         WHERE repository.state = 'active'
+           AND repository_event.kind = 'issue-assigned'
+           AND json_extract(repository_event.payload, '$.assignee') = ?2
+           AND (repository.visibility = 'public'
+                OR repository.owner_account_id = ?1
+                OR EXISTS (
+                    SELECT 1 FROM repository_collaborator
+                    WHERE repository_collaborator.repository_id = repository.id
+                      AND repository_collaborator.account_id = ?1
+                ))
+         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+         LIMIT ?3"
+    );
+    let mut statement = connection.prepare(&sql)?;
+    statement
+        .query_map(
+            rusqlite::params![account_id, username, limit],
+            activity_from_row,
+        )?
+        .collect::<Result<Vec<_>, _>>()
+        .map_err(Into::into)
+}
+
+fn mention_feed_events(
+    connection: &Connection,
+    account_id: i64,
+    username: &str,
+    limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+    let scan_limit = limit.saturating_mul(50).min(1_000);
+    let sql = format!(
+        "{ACTIVITY_SELECT}
+         WHERE repository.state = 'active'
+           AND repository_event.kind IN
+               ('issue-created', 'issue-edited', 'issue-commented')
+           AND (repository.visibility = 'public'
+                OR repository.owner_account_id = ?1
+                OR EXISTS (
+                    SELECT 1 FROM repository_collaborator
+                    WHERE repository_collaborator.repository_id = repository.id
+                      AND repository_collaborator.account_id = ?1
+                ))
+         ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+         LIMIT ?2"
+    );
+    let mut statement = connection.prepare(&sql)?;
+    let candidates = statement
+        .query_map(rusqlite::params![account_id, scan_limit], activity_from_row)?
+        .collect::<Result<Vec<_>, _>>()?;
+    let limit = usize::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+    Ok(candidates
+        .into_iter()
+        .filter(|record| event_mentions(&record.event, username))
+        .take(limit)
+        .collect())
+}
+
+fn event_mentions(event: &RepositoryEventRecord, username: &str) -> bool {
+    if event.payload_version != event::PAYLOAD_VERSION {
+        return false;
+    }
+    let Ok(payload) = serde_json::from_str::<serde_json::Value>(&event.payload) else {
+        return false;
+    };
+    let Some(body) = payload.get("body").and_then(serde_json::Value::as_str) else {
+        return false;
+    };
+    let mention = format!("@{username}");
+    body.match_indices(&mention).any(|(start, _)| {
+        let before = body[..start].chars().next_back();
+        let after = body[start + mention.len()..].chars().next();
+        !before.is_some_and(is_username_character) && !after.is_some_and(is_username_character)
+    })
+}
+
+fn is_username_character(character: char) -> bool {
+    character.is_ascii_alphanumeric() || character == '-'
 }
 
 fn find_issue(
@@ -2858,11 +3303,8 @@
     }
 }
 
-fn active_account_id(
-    transaction: &rusqlite::Transaction<'_>,
-    username: &str,
-) -> Result<i64, StoreError> {
-    let result = transaction.query_row(
+fn active_account_id(connection: &Connection, username: &str) -> Result<i64, StoreError> {
+    let result = connection.query_row(
         "SELECT id FROM account WHERE username = ?1 AND state = 'active'",
         [username],
         |row| row.get(0),
diff --git a/templates/account-page.html b/templates/account-page.html
index 4a99edbfc48f006d5608fe3ac1f2b10e5928de14..5fa0735047945fccc56de5b42de36785fcb3ce1b 100644
--- a/templates/account-page.html
+++ b/templates/account-page.html
@@ -24,6 +24,8 @@
     </p>
     <button type="submit">Create repository</button>
   </form>
+  <h2>Feeds</h2>
+  <p><a href="/feeds">Manage private feed tokens</a></p>
   <h2>Sessions</h2>
   <form action="/logout" method="post">
     <input type="hidden" name="csrf" value="{{ csrf }}">
diff --git a/templates/feed-token-issued.html b/templates/feed-token-issued.html
new file mode 100644
index 0000000000000000000000000000000000000000..24d7a06f5ec1db44aaca2b92a35d4f4706aadd92
--- /dev/null
+++ b/templates/feed-token-issued.html
@@ -1,0 +1,14 @@
+{% extends "base.html" %}
+{% block title %}New feed token · tit{% endblock %}
+{% block content %}
+  <h1>New feed token</h1>
+  <p>This token gives access to {{ scope }} for {{ target }}. Copy the URLs now. tit will not show them again.</p>
+  <dl>
+    <dt>Atom</dt>
+    <dd><code>/feeds/{{ token }}/atom.xml</code></dd>
+    <dt>RSS</dt>
+    <dd><code>/feeds/{{ token }}/rss.xml</code></dd>
+  </dl>
+  <p>Keep these URLs secret. Rotate or revoke the token if a URL is exposed.</p>
+  <p><a href="/feeds">Return to feed tokens</a></p>
+{% endblock %}
diff --git a/templates/feed-tokens.html b/templates/feed-tokens.html
new file mode 100644
index 0000000000000000000000000000000000000000..98cb691d05eba9c634a2686df60d3dcd8df016aa
--- /dev/null
+++ b/templates/feed-tokens.html
@@ -1,0 +1,55 @@
+{% extends "base.html" %}
+{% block title %}Feed tokens · tit{% endblock %}
+{% block content %}
+  <h1>Feed tokens</h1>
+  <p>Feed URLs are credentials. A person who has a URL can read its scoped feed.</p>
+
+  <h2>Create token</h2>
+  <form method="post" action="/feeds/tokens">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <div class="field">
+      <label for="feed-scope">Scope</label>
+      <select id="feed-scope" name="scope">
+        <option value="watched">Watched activity</option>
+        <option value="assignments">Assignments</option>
+        <option value="mentions">Mentions</option>
+        <option value="repository">One repository</option>
+      </select>
+    </div>
+    <div class="field">
+      <label for="feed-owner">Repository owner</label>
+      <input id="feed-owner" name="owner" autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+      <label for="feed-repository">Repository name</label>
+      <input id="feed-repository" name="repository" autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <p>Supply the owner and repository name only for a one-repository token.</p>
+    <button type="submit">Create feed token</button>
+  </form>
+
+  <h2>Existing tokens</h2>
+{% if tokens.is_empty() %}
+  <p>You do not have a feed token.</p>
+{% else %}
+  <ul>
+{% for token in tokens %}
+    <li>
+      <strong>{{ token.scope }}</strong> for {{ token.target }}, created at {{ token.created_at }}.
+{% if token.active %}
+      <form method="post" action="/feeds/tokens/{{ token.id }}/rotate">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <button type="submit">Rotate</button>
+      </form>
+      <form method="post" action="/feeds/tokens/{{ token.id }}/revoke">
+        <input type="hidden" name="csrf" value="{{ csrf }}">
+        <button type="submit">Revoke</button>
+      </form>
+{% else %}
+      Revoked.
+{% endif %}
+    </li>
+{% endfor %}
+  </ul>
+{% endif %}
+{% endblock %}
diff --git a/templates/issues.html b/templates/issues.html
index 663447fff277f0c0a25a10c5215c1b2aa14d6814..23ba41473683a9f3c3576cc31d36a1085ea81966 100644
--- a/templates/issues.html
+++ b/templates/issues.html
@@ -8,8 +8,10 @@
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
       <a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
       <a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
-      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
-      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+      <a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
+      <a href="/{{ owner }}/{{ repository }}/atom.xml">Activity Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/rss.xml">Activity RSS</a>
     </nav>
   </header>
 
diff --git a/tests/cli.rs b/tests/cli.rs
index a6c89ed5f9669230fcd004498ca45abdcb5c11cb..f8429dd99777dbb1454e1c3dce408c9c52e1fad5 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -22,7 +22,8 @@
     include_str!("../src/store/migrations/011_domain_events.sql"),
     include_str!("../src/store/migrations/012_issues.sql"),
     include_str!("../src/store/migrations/013_watches.sql"),
-    "PRAGMA user_version = 13;\n",
+    include_str!("../src/store/migrations/014_feed_tokens.sql"),
+    "PRAGMA user_version = 14;\n",
 );
 
 #[test]
diff --git a/tests/public_routes.rs b/tests/public_routes.rs
index d08117363d6d33a064e72ab9413184e876e7a1bc..8ae26cd0e021966c64beb8173b613158a095530c 100644
--- a/tests/public_routes.rs
+++ b/tests/public_routes.rs
@@ -14,6 +14,8 @@
 mod domain;
 #[path = "../src/feed.rs"]
 mod feed;
+#[path = "../src/feed_token.rs"]
+mod feed_token;
 #[allow(
     dead_code,
     reason = "the public-route test does not use each shared Git API"
@@ -661,7 +663,10 @@
     for (path, fields) in [
         (
             "/alice/example/issues/1/comments",
-            vec![("csrf", csrf.as_str()), ("body", "A **comment**.")],
+            vec![
+                ("csrf", csrf.as_str()),
+                ("body", "A **comment** for @alice."),
+            ],
         ),
         (
             "/alice/example/issues/1/edit",
@@ -725,6 +730,25 @@
     let feed = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
     assert_eq!(feed.status, 200);
     assert!(feed.text().contains("alice reopened #1"));
+    for (path, content_type) in [
+        (
+            "/alice/example/issues/atom.xml",
+            "application/atom+xml; charset=utf-8",
+        ),
+        (
+            "/alice/example/issues/rss.xml",
+            "application/rss+xml; charset=utf-8",
+        ),
+    ] {
+        let issue_feed = request(server.address(), "GET", path, &[], &[]);
+        assert_eq!(issue_feed.status, 200);
+        assert_eq!(issue_feed.header("content-type"), content_type);
+        let parsed = feed_rs::parser::parse(issue_feed.body.as_slice())
+            .expect("parse the public issue feed");
+        assert!(!parsed.entries.is_empty());
+        assert!(issue_feed.text().contains("reopened #1"));
+        assert!(!issue_feed.text().contains("Repository imported"));
+    }
 
     let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
     assert_eq!(anonymous_watch.status, 200);
@@ -774,6 +798,184 @@
             .contains("You watch selected activity in this repository.")
     );
     assert_eq!(selected.text().matches("value=\"1\" selected").count(), 3);
+
+    let feed_tokens = request(
+        server.address(),
+        "GET",
+        "/feeds",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert_eq!(feed_tokens.status, 200);
+    assert!(feed_tokens.text().contains("Feed URLs are credentials."));
+    let issue_repository_token = form(&[
+        ("csrf", csrf.as_str()),
+        ("scope", "repository"),
+        ("owner", "alice"),
+        ("repository", "example"),
+    ]);
+    let issued = request(
+        server.address(),
+        "POST",
+        "/feeds/tokens",
+        &headers,
+        issue_repository_token.as_bytes(),
+    );
+    assert_eq!(issued.status, 201);
+    assert!(issued.text().contains("tit will not show them again."));
+    let private_token = extract_feed_token(issued.text());
+    let private_path = format!("/feeds/{private_token}/atom.xml");
+    let private_feed = request(server.address(), "GET", &private_path, &[], &[]);
+    assert_eq!(private_feed.status, 200);
+    assert_eq!(private_feed.header("cache-control"), "private, no-store");
+    assert_eq!(private_feed.header("referrer-policy"), "no-referrer");
+    assert!(private_feed.text().contains("alice/example"));
+    let private_hash: [u8; 32] = Sha256::digest(private_token.as_bytes()).into();
+    let token_store = Store::open(&database).expect("open the feed token database");
+    let (private_id, stored_hash): (String, Vec<u8>) = token_store
+        .connection()
+        .query_row(
+            "SELECT id, token_hash FROM feed_token
+             WHERE scope = 'repository' AND revoked_at IS NULL",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?)),
+        )
+        .expect("read the stored feed token hash");
+    assert_eq!(stored_hash, private_hash);
+    assert_ne!(stored_hash, private_token.as_bytes());
+    drop(token_store);
+    let hidden_token = request(
+        server.address(),
+        "GET",
+        "/feeds",
+        &[("Cookie", cookie.as_str())],
+        &[],
+    );
+    assert!(!hidden_token.text().contains(&private_token));
+
+    let private_store = Store::open(&database).expect("open the private feed database");
+    private_store
+        .connection()
+        .execute(
+            "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
+            [],
+        )
+        .expect("make the feed repository private");
+    drop(private_store);
+    assert_eq!(
+        request(
+            server.address(),
+            "GET",
+            "/alice/example/issues/atom.xml",
+            &[],
+            &[],
+        )
+        .status,
+        404
+    );
+    assert_eq!(
+        request(server.address(), "GET", &private_path, &[], &[]).status,
+        200
+    );
+
+    let rotate = form(&[("csrf", csrf.as_str())]);
+    let rotated = request(
+        server.address(),
+        "POST",
+        &format!("/feeds/tokens/{private_id}/rotate"),
+        &headers,
+        rotate.as_bytes(),
+    );
+    assert_eq!(rotated.status, 201);
+    let rotated_token = extract_feed_token(rotated.text());
+    assert_ne!(rotated_token, private_token);
+    assert_eq!(
+        request(server.address(), "GET", &private_path, &[], &[]).status,
+        404
+    );
+    let rotated_path = format!("/feeds/{rotated_token}/rss.xml");
+    assert_eq!(
+        request(server.address(), "GET", &rotated_path, &[], &[]).status,
+        200
+    );
+    let rotated_hash: [u8; 32] = Sha256::digest(rotated_token.as_bytes()).into();
+    let rotated_store = Store::open(&database).expect("open the rotated feed database");
+    let rotated_id: String = rotated_store
+        .connection()
+        .query_row(
+            "SELECT id FROM feed_token WHERE token_hash = ?1 AND revoked_at IS NULL",
+            [rotated_hash.as_slice()],
+            |row| row.get(0),
+        )
+        .expect("read the rotated feed token ID");
+    drop(rotated_store);
+    let revoked = request(
+        server.address(),
+        "POST",
+        &format!("/feeds/tokens/{rotated_id}/revoke"),
+        &headers,
+        rotate.as_bytes(),
+    );
+    assert_eq!(revoked.status, 303);
+    assert_eq!(
+        request(server.address(), "GET", &rotated_path, &[], &[]).status,
+        404
+    );
+
+    for (scope, expected, excluded, format) in [
+        (
+            "watched",
+            "commented on #1",
+            "no excluded event",
+            "atom.xml",
+        ),
+        (
+            "assignments",
+            "assigned alice on #1",
+            "commented on #1",
+            "rss.xml",
+        ),
+        (
+            "mentions",
+            "commented on #1",
+            "assigned alice on #1",
+            "atom.xml",
+        ),
+    ] {
+        let body = form(&[
+            ("csrf", csrf.as_str()),
+            ("scope", scope),
+            ("owner", ""),
+            ("repository", ""),
+        ]);
+        let issued = request(
+            server.address(),
+            "POST",
+            "/feeds/tokens",
+            &headers,
+            body.as_bytes(),
+        );
+        assert_eq!(issued.status, 201, "did not issue the {scope} token");
+        let token = extract_feed_token(issued.text());
+        let response = request(
+            server.address(),
+            "GET",
+            &format!("/feeds/{token}/{format}"),
+            &[],
+            &[],
+        );
+        assert_eq!(response.status, 200, "did not read the {scope} feed");
+        feed_rs::parser::parse(response.body.as_slice())
+            .unwrap_or_else(|_| panic!("parse the {scope} feed"));
+        assert!(response.text().contains(expected), "wrong {scope} feed");
+        if excluded != "no excluded event" {
+            assert!(
+                !response.text().contains(excluded),
+                "the {scope} token escaped its scope"
+            );
+        }
+    }
+
     let none = form(&[
         ("csrf", csrf.as_str()),
         ("pushes", "0"),
@@ -807,6 +1009,15 @@
     let mut serializer = url::form_urlencoded::Serializer::new(String::new());
     serializer.extend_pairs(fields.iter().copied());
     serializer.finish()
+}
+
+fn extract_feed_token(body: &str) -> String {
+    let marker = "/feeds/";
+    let start = body.find(marker).expect("find a feed URL") + marker.len();
+    let token = body[start..].split('/').next().expect("read a feed token");
+    assert_eq!(token.len(), 64);
+    assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
+    token.to_owned()
 }
 
 fn assert_hidden(address: SocketAddr, head: &str) {
diff --git a/tests/sqlite.rs b/tests/sqlite.rs
index 61a64be7ef87ed12de57de4f680257abf1267629..094b2b018aa7bf1b3d631269595cf19d51d40c67 100644
--- a/tests/sqlite.rs
+++ b/tests/sqlite.rs
@@ -57,6 +57,16 @@
     include_str!("../src/store/migrations/012_issues.sql"),
     "PRAGMA user_version = 12;\n",
 );
+const V13_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    include_str!("../src/store/migrations/011_domain_events.sql"),
+    include_str!("../src/store/migrations/012_issues.sql"),
+    include_str!("../src/store/migrations/013_watches.sql"),
+    "PRAGMA user_version = 13;\n",
+);
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -174,7 +184,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 13);
+    assert_eq!(store.schema_version().expect("read the schema version"), 14);
     assert_eq!(
         store
             .connection()
@@ -971,6 +981,145 @@
             .is_err()
     );
 
+    store
+        .set_watch(
+            "alice",
+            "project",
+            "bob",
+            WatchPreferences {
+                pushes: false,
+                issues: true,
+                pull_requests: false,
+            },
+            20,
+        )
+        .expect("restore the issue watch");
+    store
+        .comment_issue(
+            "alice",
+            "project",
+            1,
+            "carol",
+            "Please review this, @bob. Do not match @bobby.",
+            21,
+        )
+        .expect("create a mention event");
+    let repository_token = store
+        .create_feed_token(
+            "bob",
+            "repository",
+            Some(("alice", "project")),
+            &[1; 32],
+            22,
+        )
+        .expect("create a private repository feed token");
+    let watched_token = store
+        .create_feed_token("bob", "watched", None, &[2; 32], 23)
+        .expect("create a watched feed token");
+    let assignment_token = store
+        .create_feed_token("bob", "assignments", None, &[3; 32], 24)
+        .expect("create an assignment feed token");
+    let mention_token = store
+        .create_feed_token("bob", "mentions", None, &[4; 32], 25)
+        .expect("create a mention feed token");
+    assert!(matches!(
+        store.create_feed_token(
+            "stranger",
+            "repository",
+            Some(("alice", "project")),
+            &[5; 32],
+            26,
+        ),
+        Err(StoreError::FeedTokenDenied)
+    ));
+    assert!(matches!(
+        store.create_feed_token("bob", "repository", None, &[5; 32], 26),
+        Err(StoreError::InvalidFeedScope)
+    ));
+    let private_page = store
+        .token_feed_events(&[1; 32], 100)
+        .expect("read a private repository feed");
+    assert_eq!(private_page.scope, "repository");
+    assert_eq!(private_page.username, "bob");
+    assert_eq!(
+        private_page.target.as_deref(),
+        Some("00112233445566778899aabbccddeeff")
+    );
+    assert!(
+        private_page
+            .events
+            .iter()
+            .all(|event| event.repository.slug == "project")
+    );
+    let watched_page = store
+        .token_feed_events(&[2; 32], 100)
+        .expect("read watched issue activity");
+    assert!(
+        watched_page
+            .events
+            .iter()
+            .all(|event| event.event.kind.starts_with("issue-"))
+    );
+    assert!(!watched_page.events.is_empty());
+    let assignment_page = store
+        .token_feed_events(&[3; 32], 100)
+        .expect("read assignment activity");
+    assert_eq!(assignment_page.events.len(), 1);
+    assert_eq!(assignment_page.events[0].event.kind, "issue-assigned");
+    let mention_page = store
+        .token_feed_events(&[4; 32], 100)
+        .expect("read mention activity");
+    assert_eq!(mention_page.events.len(), 1);
+    assert_eq!(mention_page.events[0].event.kind, "issue-commented");
+    assert_eq!(store.feed_tokens("bob").expect("list feed tokens").len(), 4);
+    store
+        .connection()
+        .execute_batch(
+            "CREATE TEMP TRIGGER reject_feed_token
+             BEFORE INSERT ON feed_token
+             BEGIN
+                 SELECT RAISE(ABORT, 'injected feed token failure');
+             END;",
+        )
+        .expect("inject a feed token failure");
+    assert!(matches!(
+        store.rotate_feed_token("bob", &repository_token.id, &[7; 32], 27),
+        Err(StoreError::Sqlite(_))
+    ));
+    store
+        .token_feed_events(&[1; 32], 100)
+        .expect("retain the old token after a failed rotation");
+    store
+        .connection()
+        .execute_batch("DROP TRIGGER reject_feed_token;")
+        .expect("remove the feed token failure");
+    let rotated = store
+        .rotate_feed_token("bob", &repository_token.id, &[6; 32], 28)
+        .expect("rotate a repository feed token");
+    assert_eq!(rotated.scope, repository_token.scope);
+    assert!(matches!(
+        store.token_feed_events(&[1; 32], 100),
+        Err(StoreError::FeedTokenNotFound)
+    ));
+    store
+        .revoke_feed_token("bob", &rotated.id, 29)
+        .expect("revoke a repository feed token");
+    assert!(matches!(
+        store.token_feed_events(&[6; 32], 100),
+        Err(StoreError::FeedTokenNotFound)
+    ));
+    assert!(
+        store
+            .repository_issue_events("alice", "project", None, 100)
+            .expect("read issue-only events")
+            .1
+            .iter()
+            .all(|event| event.kind.starts_with("issue-"))
+    );
+    assert_eq!(watched_token.scope, "watched");
+    assert_eq!(assignment_token.scope, "assignments");
+    assert_eq!(mention_token.scope, "mentions");
+
     let comments_before: i64 = store
         .connection()
         .query_row("SELECT count(*) FROM issue_comment", [], |row| row.get(0))
@@ -997,6 +1146,33 @@
             .expect("count comments after rollback"),
         comments_before
     );
+    store
+        .connection()
+        .execute(
+            "DELETE FROM repository_collaborator
+             WHERE repository_id = '00112233445566778899aabbccddeeff'
+               AND account_id = 2",
+            [],
+        )
+        .expect("remove the feed account repository role");
+    for hash in [[2; 32], [3; 32], [4; 32]] {
+        assert!(
+            store
+                .token_feed_events(&hash, 100)
+                .expect("read a feed after role removal")
+                .events
+                .is_empty()
+        );
+    }
+    for value in 10_u8..39 {
+        store
+            .create_feed_token("bob", "mentions", None, &[value; 32], 30 + i64::from(value))
+            .expect("fill the active feed token limit");
+    }
+    assert!(matches!(
+        store.create_feed_token("bob", "mentions", None, &[40; 32], 70),
+        Err(StoreError::FeedTokenLimit)
+    ));
 }
 
 #[test]
@@ -1339,13 +1515,14 @@
         (V10_FIXTURE, 10),
         (V11_FIXTURE, 11),
         (V12_FIXTURE, 12),
+        (V13_FIXTURE, 13),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 13);
+        assert_eq!(store.schema_version().expect("read the schema version"), 14);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1418,7 +1595,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 13)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 14)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);
diff --git a/tests/web_shell.rs b/tests/web_shell.rs
index 02bef1e3df7c41e02f4ff52c115272e933eb039b..6340278918c845c11c8268cd9cb69a8b0f05f54e 100644
--- a/tests/web_shell.rs
+++ b/tests/web_shell.rs
@@ -15,6 +15,8 @@
 mod domain;
 #[path = "../src/feed.rs"]
 mod feed;
+#[path = "../src/feed_token.rs"]
+mod feed_token;
 #[allow(dead_code, reason = "the shell test does not use each shared Git API")]
 #[path = "../src/git/mod.rs"]
 mod git;
