diff --git a/Cargo.lock b/Cargo.lock
index 1318cc9770..97c547fdd4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -604,6 +604,24 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "crossbeam-queue"
 version = "0.3.12"
@@ -1593,6 +1611,23 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "moka"
+version = "0.12.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
 [[package]]
 name = "negentropy"
 version = "0.3.1"
@@ -1883,6 +1918,12 @@ dependencies = [
  "universal-hash",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
 [[package]]
 name = "potential_utf"
 version = "0.1.4"
@@ -2803,6 +2844,7 @@ dependencies = [
  "deadpool-redis",
  "futures-util",
  "hex",
+ "moka",
  "nostr",
  "redis",
  "serde",
@@ -2849,11 +2891,15 @@ name = "sprout-test-client"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "base64",
+ "chrono",
  "futures-util",
+ "hex",
  "nostr",
  "reqwest",
  "serde",
  "serde_json",
+ "sha2",
  "sprout-core",
  "sprout-mcp",
  "thiserror",
@@ -3143,6 +3189,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
 [[package]]
 name = "thiserror"
 version = "2.0.18"
diff --git a/Cargo.toml b/Cargo.toml
index 70bdd281e9..2aac4ffb91 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ rand = "0.8"
 
 # Concurrent data structures
 dashmap = "6"
+moka    = { version = "0.12", features = ["sync"] }
 
 # JWT validation (Okta JWKS)
 jsonwebtoken = "9"
diff --git a/crates/sprout-auth/src/error.rs b/crates/sprout-auth/src/error.rs
index 5ec16ac414..b968a73921 100644
--- a/crates/sprout-auth/src/error.rs
+++ b/crates/sprout-auth/src/error.rs
@@ -34,6 +34,27 @@ pub enum AuthError {
     #[error("api token invalid or expired")]
     TokenInvalid,
 
+    /// The API token was found in the database but has been revoked.
+    ///
+    /// Distinct from [`TokenInvalid`] so the relay can return `401 token_revoked`
+    /// rather than the generic `invalid_token` error code.
+    #[error("api token has been revoked")]
+    TokenRevoked,
+
+    /// The API token was found in the database but has passed its expiry timestamp.
+    ///
+    /// Distinct from [`TokenInvalid`] so the relay can return `401 token_expired`
+    /// rather than the generic `invalid_token` error code.
+    #[error("api token has expired")]
+    TokenExpired,
+
+    /// NIP-98 HTTP Auth event (kind:27235) failed verification.
+    ///
+    /// The inner string describes the specific failure (signature, timestamp, URL, etc.)
+    /// and is safe to include in server logs. Do **not** forward raw event content to clients.
+    #[error("NIP-98 HTTP Auth verification failed: {0}")]
+    Nip98Invalid(String),
+
     /// The pubkey in the NIP-42 event does not match the identity in the JWT or API token.
     #[error("pubkey mismatch: event pubkey does not match authenticated identity")]
     PubkeyMismatch,
diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs
index fde12693fb..39584e34a5 100644
--- a/crates/sprout-auth/src/lib.rs
+++ b/crates/sprout-auth/src/lib.rs
@@ -21,6 +21,8 @@ pub mod access;
 pub mod error;
 /// NIP-42 challenge–response authentication.
 pub mod nip42;
+/// NIP-98 HTTP Auth verification (kind:27235).
+pub mod nip98;
 /// Okta OIDC integration and JWKS validation.
 pub mod okta;
 /// Per-connection rate limiting.
@@ -33,11 +35,12 @@ pub mod token;
 pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
 pub use error::AuthError;
 pub use nip42::{generate_challenge, verify_nip42_event};
+pub use nip98::verify_nip98_event;
 pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig};
 pub use rate_limit::{
     ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
 };
-pub use scope::{parse_scopes, Scope};
+pub use scope::{is_self_mintable, parse_scopes, Scope, SELF_MINTABLE_SCOPES};
 pub use token::{generate_token, hash_token, verify_token_hash};
 
 #[cfg(any(test, feature = "test-utils"))]
diff --git a/crates/sprout-auth/src/nip98.rs b/crates/sprout-auth/src/nip98.rs
new file mode 100644
index 0000000000..746b7ecdb6
--- /dev/null
+++ b/crates/sprout-auth/src/nip98.rs
@@ -0,0 +1,326 @@
+//! NIP-98 HTTP Auth verification (kind:27235).
+//!
+//! NIP-98 is the standard Nostr HTTP Auth pattern used by Nostr.build, Blossom, and
+//! other Nostr HTTP services. It is **stateless** — no WebSocket session required.
+//!
+//! The client signs a short-lived kind:27235 event containing the target URL, HTTP method,
+//! and an optional SHA-256 hash of the request body, then sends it as:
+//!
+//! ```text
+//! Authorization: Nostr <base64(JSON-serialized-event)>
+//! ```
+//!
+//! ## Verification steps
+//!
+//! 1. Parse JSON into a `nostr::Event`
+//! 2. Verify `kind == 27235` (`Kind::HttpAuth`)
+//! 3. Verify Schnorr signature via `sprout_core::verify_event`
+//! 4. Verify `created_at` within ±60 seconds of server time
+//! 5. Verify `["u", <url>]` tag matches `expected_url` (normalised: case-insensitive
+//!    scheme/host, trailing slash stripped)
+//! 6. Verify `["method", <method>]` tag matches `expected_method` (case-insensitive)
+//! 7. If `["payload", <hash>]` tag is present **and** `body` is `Some`: verify
+//!    `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks.
+//! 8. Return `event.pubkey` on success.
+
+use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp};
+use sha2::{Digest, Sha256};
+use url::Url;
+
+use crate::error::AuthError;
+
+const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
+
+/// Verify a NIP-98 HTTP Auth event (kind:27235).
+///
+/// # Parameters
+///
+/// - `event_json` — the raw JSON string of the Nostr event (decoded from base64 by the caller).
+/// - `expected_url` — the canonical URL of the request being authenticated.
+///   For reverse-proxy deployments, reconstruct from `X-Forwarded-Proto` / `X-Forwarded-Host`
+///   before passing here.
+/// - `expected_method` — the HTTP method (e.g. `"POST"`). Compared case-insensitively.
+/// - `body` — raw request body bytes. If `Some` and a `payload` tag is present in the event,
+///   the SHA-256 hash of `body` must match the tag value. If `None`, the `payload` tag is
+///   ignored (clients SHOULD include it for POST requests, but it is not required).
+///
+/// # Returns
+///
+/// The authenticated `nostr::PublicKey` on success.
+///
+/// # Errors
+///
+/// Returns [`AuthError::Nip98Invalid`] with a descriptive message for any verification failure.
+/// The message is safe for server logs but should not be forwarded verbatim to clients.
+pub fn verify_nip98_event(
+    event_json: &str,
+    expected_url: &str,
+    expected_method: &str,
+    body: Option<&[u8]>,
+) -> Result<nostr::PublicKey, AuthError> {
+    // 1. Parse JSON.
+    let event: Event = serde_json::from_str(event_json)
+        .map_err(|e| AuthError::Nip98Invalid(format!("event JSON parse error: {e}")))?;
+
+    // 2. Verify kind == 27235.
+    if event.kind != Kind::HttpAuth {
+        return Err(AuthError::Nip98Invalid(format!(
+            "expected kind 27235, got {}",
+            event.kind.as_u16()
+        )));
+    }
+
+    // 3. Verify Schnorr signature (also verifies event ID hash).
+    sprout_core::verify_event(&event)
+        .map_err(|_| AuthError::Nip98Invalid("invalid Schnorr signature".to_string()))?;
+
+    // 4. Verify created_at within ±60 seconds of now.
+    let now = Timestamp::now().as_u64();
+    let event_ts = event.created_at.as_u64();
+    let delta = now.abs_diff(event_ts);
+    if delta > TIMESTAMP_TOLERANCE_SECS {
+        return Err(AuthError::Nip98Invalid(format!(
+            "event timestamp outside ±{TIMESTAMP_TOLERANCE_SECS}s window (delta: {delta}s)"
+        )));
+    }
+
+    // 5. Verify `u` tag matches expected_url (normalised).
+    // NIP-98 uses the single-letter "u" tag, not the multi-letter "url" tag.
+    let u_tag = event
+        .tags
+        .find(TagKind::SingleLetter(SingleLetterTag::lowercase(
+            Alphabet::U,
+        )))
+        .and_then(|t| t.content())
+        .ok_or_else(|| AuthError::Nip98Invalid("missing `u` tag".to_string()))?;
+
+    if normalize_url(u_tag) != normalize_url(expected_url) {
+        return Err(AuthError::Nip98Invalid(format!(
+            "URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
+        )));
+    }
+
+    // 6. Verify `method` tag matches expected_method (case-insensitive).
+    let method_tag = event
+        .tags
+        .find(TagKind::Method)
+        .and_then(|t| t.content())
+        .ok_or_else(|| AuthError::Nip98Invalid("missing `method` tag".to_string()))?;
+
+    if !method_tag.eq_ignore_ascii_case(expected_method) {
+        return Err(AuthError::Nip98Invalid(format!(
+            "method mismatch: event has `{method_tag}`, expected `{expected_method}`"
+        )));
+    }
+
+    // 7. If `payload` tag present AND body is Some: verify SHA-256(body) == payload hex.
+    let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
+
+    if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
+        let computed: [u8; 32] = Sha256::digest(body_bytes).into();
+        let computed_hex = hex::encode(computed);
+        if computed_hex != payload_hex {
+            return Err(AuthError::Nip98Invalid(
+                "payload tag SHA-256 mismatch: request body does not match signed hash".to_string(),
+            ));
+        }
+    }
+
+    // 8. Return the authenticated pubkey.
+    Ok(event.pubkey)
+}
+
+/// Normalize a URL for comparison.
+///
+/// - Lowercases scheme and host (already done by the `url` crate).
+/// - Strips trailing slash from path.
+/// - Treats `localhost` and `::1` as equivalent to `127.0.0.1`.
+fn normalize_url(raw: &str) -> String {
+    let mut parsed = match Url::parse(raw) {
+        Ok(u) => u,
+        Err(_) => return raw.to_lowercase(),
+    };
+    if let Some(host) = parsed.host_str() {
+        if host == "localhost" || host == "::1" {
+            let _ = parsed.set_host(Some("127.0.0.1"));
+        }
+    }
+    let path = parsed.path().trim_end_matches('/').to_string();
+    parsed.set_path(&path);
+    parsed.to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use nostr::{EventBuilder, Keys, Kind, Timestamp};
+
+    const TEST_URL: &str = "https://relay.example.com/api/tokens";
+    const TEST_METHOD: &str = "POST";
+
+    fn make_nip98_event(
+        keys: &Keys,
+        url: &str,
+        method: &str,
+        payload_hex: Option<&str>,
+        created_at: Option<Timestamp>,
+    ) -> String {
+        use nostr::Tag;
+
+        let mut tags = vec![
+            Tag::parse(&["u", url]).unwrap(),
+            Tag::parse(&["method", method]).unwrap(),
+        ];
+        if let Some(hex) = payload_hex {
+            tags.push(Tag::parse(&["payload", hex]).unwrap());
+        }
+
+        let mut builder = EventBuilder::new(Kind::HttpAuth, "", tags);
+        if let Some(ts) = created_at {
+            builder = builder.custom_created_at(ts);
+        }
+        let event = builder.sign_with_keys(keys).expect("sign");
+        serde_json::to_string(&event).expect("serialize")
+    }
+
+    #[test]
+    fn valid_event_returns_pubkey() {
+        let keys = Keys::generate();
+        let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(result.is_ok(), "verify failed: {:?}", result.err());
+        assert_eq!(result.unwrap(), keys.public_key());
+    }
+
+    #[test]
+    fn wrong_kind_rejected() {
+        let keys = Keys::generate();
+        let event = EventBuilder::new(Kind::TextNote, "", [])
+            .sign_with_keys(&keys)
+            .expect("sign");
+        let json = serde_json::to_string(&event).unwrap();
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
+    }
+
+    #[test]
+    fn expired_timestamp_rejected() {
+        let keys = Keys::generate();
+        let old_ts = Timestamp::from(Timestamp::now().as_u64().saturating_sub(120));
+        let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, Some(old_ts));
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
+    }
+
+    #[test]
+    fn url_mismatch_rejected() {
+        let keys = Keys::generate();
+        let json = make_nip98_event(
+            &keys,
+            "https://other.example.com/api/tokens",
+            TEST_METHOD,
+            None,
+            None,
+        );
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
+    }
+
+    #[test]
+    fn method_mismatch_rejected() {
+        let keys = Keys::generate();
+        let json = make_nip98_event(&keys, TEST_URL, "GET", None, None);
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
+    }
+
+    #[test]
+    fn method_case_insensitive() {
+        let keys = Keys::generate();
+        let json = make_nip98_event(&keys, TEST_URL, "post", None, None);
+        let result = verify_nip98_event(&json, TEST_URL, "POST", None);
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn payload_tag_correct_hash_passes() {
+        let keys = Keys::generate();
+        let body = b"hello world";
+        let hash: [u8; 32] = Sha256::digest(body).into();
+        let hash_hex = hex::encode(hash);
+        let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&hash_hex), None);
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn payload_tag_wrong_hash_rejected() {
+        let keys = Keys::generate();
+        let body = b"hello world";
+        let wrong_hex = "deadbeef".repeat(8); // 64 hex chars but wrong hash
+        let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&wrong_hex), None);
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
+        assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
+    }
+
+    #[test]
+    fn payload_tag_absent_with_body_passes() {
+        // payload tag is optional per spec; clients SHOULD include it but it's not required
+        let keys = Keys::generate();
+        let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn trailing_slash_normalized() {
+        let keys = Keys::generate();
+        let url_with_slash = "https://relay.example.com/api/tokens/";
+        let json = make_nip98_event(&keys, url_with_slash, TEST_METHOD, None, None);
+        // expected_url without trailing slash — should still match
+        let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn localhost_normalized() {
+        let keys = Keys::generate();
+        let localhost_url = "http://localhost:3000/api/tokens";
+        let loopback_url = "http://127.0.0.1:3000/api/tokens";
+        let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
+        let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
+        assert!(result.is_ok());
+    }
+
+    #[test]
+    fn is_self_mintable_all_seven() {
+        use crate::scope::{is_self_mintable, Scope};
+        assert!(is_self_mintable(&Scope::MessagesRead));
+        assert!(is_self_mintable(&Scope::MessagesWrite));
+        assert!(is_self_mintable(&Scope::ChannelsRead));
+        assert!(is_self_mintable(&Scope::ChannelsWrite));
+        assert!(is_self_mintable(&Scope::UsersRead));
+        assert!(is_self_mintable(&Scope::FilesRead));
+        assert!(is_self_mintable(&Scope::FilesWrite));
+    }
+
+    #[test]
+    fn is_self_mintable_admin_scopes_false() {
+        use crate::scope::{is_self_mintable, Scope};
+        assert!(!is_self_mintable(&Scope::AdminChannels));
+        assert!(!is_self_mintable(&Scope::UsersWrite));
+        assert!(!is_self_mintable(&Scope::AdminUsers));
+        assert!(!is_self_mintable(&Scope::JobsRead));
+        assert!(!is_self_mintable(&Scope::JobsWrite));
+        assert!(!is_self_mintable(&Scope::SubscriptionsRead));
+        assert!(!is_self_mintable(&Scope::SubscriptionsWrite));
+    }
+
+    #[test]
+    fn is_self_mintable_unknown_false() {
+        use crate::scope::{is_self_mintable, Scope};
+        assert!(!is_self_mintable(&Scope::Unknown(
+            "future:scope".to_string()
+        )));
+    }
+}
diff --git a/crates/sprout-auth/src/scope.rs b/crates/sprout-auth/src/scope.rs
index 72761c45d5..7a4af3e0b9 100644
--- a/crates/sprout-auth/src/scope.rs
+++ b/crates/sprout-auth/src/scope.rs
@@ -47,6 +47,51 @@ pub enum Scope {
 }
 
 impl Scope {
+    /// Return a `Vec` containing every known scope variant.
+    ///
+    /// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
+    /// auth grants unrestricted access — there is no token to derive scopes from.
+    pub fn all_known() -> Vec<Scope> {
+        vec![
+            Self::MessagesRead,
+            Self::MessagesWrite,
+            Self::ChannelsRead,
+            Self::ChannelsWrite,
+            Self::AdminChannels,
+            Self::UsersRead,
+            Self::UsersWrite,
+            Self::AdminUsers,
+            Self::JobsRead,
+            Self::JobsWrite,
+            Self::SubscriptionsRead,
+            Self::SubscriptionsWrite,
+            Self::FilesRead,
+            Self::FilesWrite,
+        ]
+    }
+
+    /// Return a `Vec` containing every known scope variant except admin scopes.
+    ///
+    /// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
+    /// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
+    /// a real token even in dev mode, so they are excluded here.
+    pub fn all_non_admin() -> Vec<Scope> {
+        vec![
+            Self::MessagesRead,
+            Self::MessagesWrite,
+            Self::ChannelsRead,
+            Self::ChannelsWrite,
+            Self::UsersRead,
+            Self::UsersWrite,
+            Self::JobsRead,
+            Self::JobsWrite,
+            Self::SubscriptionsRead,
+            Self::SubscriptionsWrite,
+            Self::FilesRead,
+            Self::FilesWrite,
+        ]
+    }
+
     /// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
     pub fn as_str(&self) -> &str {
         match self {
@@ -99,6 +144,39 @@ impl FromStr for Scope {
     }
 }
 
+/// Scopes that can be self-minted via `POST /api/tokens`.
+///
+/// Admin-only scopes (`AdminChannels`, `UsersWrite`, `AdminUsers`, `JobsRead`, `JobsWrite`,
+/// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require
+/// `sprout-admin mint-token`.
+pub const SELF_MINTABLE_SCOPES: &[Scope] = &[
+    Scope::MessagesRead,
+    Scope::MessagesWrite,
+    Scope::ChannelsRead,
+    Scope::ChannelsWrite,
+    Scope::UsersRead,
+    Scope::FilesRead,
+    Scope::FilesWrite,
+];
+
+/// Returns `true` if the given scope may be requested via `POST /api/tokens`.
+///
+/// Admin-only scopes and `Scope::Unknown` always return `false`.
+/// Unknown scope strings are rejected at mint time rather than silently accepted —
+/// a client sending an unrecognised scope string likely has a bug.
+pub fn is_self_mintable(scope: &Scope) -> bool {
+    matches!(
+        scope,
+        Scope::MessagesRead
+            | Scope::MessagesWrite
+            | Scope::ChannelsRead
+            | Scope::ChannelsWrite
+            | Scope::UsersRead
+            | Scope::FilesRead
+            | Scope::FilesWrite
+    )
+}
+
 /// Parse a slice of scope strings into `Vec<Scope>`.
 pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
     raw.iter()
@@ -134,4 +212,49 @@ mod tests {
         let scopes = parse_scopes(&["messages:read", "channels:write"]);
         assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
     }
+
+    #[test]
+    fn all_non_admin_excludes_admin_scopes() {
+        let scopes = Scope::all_non_admin();
+        assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
+        // Verify no duplicates
+        let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
+        assert_eq!(
+            unique.len(),
+            12,
+            "all_non_admin() must not contain duplicates"
+        );
+        // Verify no Unknown variants
+        for scope in &scopes {
+            assert!(
+                !matches!(scope, Scope::Unknown(_)),
+                "all_non_admin() must not contain Unknown variants"
+            );
+        }
+        // Verify admin scopes are excluded
+        assert!(
+            !scopes.contains(&Scope::AdminChannels),
+            "all_non_admin() must not contain AdminChannels"
+        );
+        assert!(
+            !scopes.contains(&Scope::AdminUsers),
+            "all_non_admin() must not contain AdminUsers"
+        );
+    }
+
+    #[test]
+    fn all_known_returns_all_14_variants() {
+        let all = Scope::all_known();
+        assert_eq!(all.len(), 14, "expected 14 known scope variants");
+        // Verify no duplicates
+        let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
+        assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
+        // Verify no Unknown variants
+        for scope in &all {
+            assert!(
+                !matches!(scope, Scope::Unknown(_)),
+                "all_known() must not contain Unknown variants"
+            );
+        }
+    }
 }
diff --git a/crates/sprout-db/src/api_token.rs b/crates/sprout-db/src/api_token.rs
index 07feb9a958..51a4d6c572 100644
--- a/crates/sprout-db/src/api_token.rs
+++ b/crates/sprout-db/src/api_token.rs
@@ -1,10 +1,11 @@
 //! API token CRUD operations.
 
 use chrono::{DateTime, Utc};
-use sqlx::MySqlPool;
+use sqlx::{MySqlPool, Row};
 use uuid::Uuid;
 
 use crate::error::{DbError, Result};
+use crate::event::uuid_from_bytes;
 
 /// Create a new API token record. The caller is responsible for generating
 /// the raw token and computing its SHA-256 hash.
@@ -49,3 +50,248 @@ pub async fn create_api_token(
 
     Ok(id)
 }
+
+/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
+///
+/// Uses a `SELECT ... WHERE COUNT < 10` subquery so the check and insert are atomic —
+/// no TOCTOU race between a separate count query and the insert.
+///
+/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded.
+pub async fn create_api_token_if_under_limit(
+    pool: &MySqlPool,
+    token_hash: &[u8],
+    owner_pubkey: &[u8],
+    name: &str,
+    scopes: &[String],
+    channel_ids: Option<&[Uuid]>,
+    expires_at: Option<DateTime<Utc>>,
+) -> Result<Option<Uuid>> {
+    let id = Uuid::new_v4();
+    let id_bytes = id.as_bytes().as_slice();
+
+    let scopes_json =
+        serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
+
+    let channel_ids_json: Option<serde_json::Value> = channel_ids
+        .map(|ids| {
+            serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
+                .map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
+        })
+        .transpose()?;
+
+    // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10.
+    // The subquery and insert execute atomically — no separate count + insert race.
+    let result = sqlx::query(
+        r#"
+        INSERT INTO api_tokens
+            (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
+        SELECT ?, ?, ?, ?, ?, ?, ?, TRUE
+        FROM DUAL
+        WHERE (
+            SELECT COUNT(*)
+            FROM api_tokens
+            WHERE owner_pubkey = ?
+              AND revoked_at IS NULL
+              AND (expires_at IS NULL OR expires_at > NOW())
+        ) < 10
+        "#,
+    )
+    .bind(id_bytes)
+    .bind(token_hash)
+    .bind(owner_pubkey)
+    .bind(name)
+    .bind(&scopes_json)
+    .bind(&channel_ids_json)
+    .bind(expires_at)
+    .bind(owner_pubkey)
+    .execute(pool)
+    .await?;
+
+    if result.rows_affected() == 0 {
+        // Limit exceeded — the WHERE clause prevented the INSERT.
+        return Ok(None);
+    }
+
+    Ok(Some(id))
+}
+
+/// Look up an API token by its SHA-256 hash, **including revoked tokens**.
+///
+/// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`),
+/// this function returns the full record regardless of revocation status.
+/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
+/// error responses rather than treating both as "not found".
+pub async fn get_api_token_by_hash_including_revoked(
+    pool: &MySqlPool,
+    hash: &[u8],
+) -> Result<Option<crate::ApiTokenRecord>> {
+    let row = sqlx::query(
+        r#"
+        SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
+               created_at, expires_at, last_used_at, revoked_at
+        FROM api_tokens
+        WHERE token_hash = ?
+        "#,
+    )
+    .bind(hash)
+    .fetch_optional(pool)
+    .await?;
+
+    let row = match row {
+        None => return Ok(None),
+        Some(r) => r,
+    };
+
+    let id_bytes: Vec<u8> = row.try_get("id")?;
+    let id = uuid_from_bytes(&id_bytes)?;
+
+    let scopes_json: serde_json::Value = row.try_get("scopes")?;
+    let scopes: Vec<String> = serde_json::from_value(scopes_json)
+        .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
+
+    let channel_ids: Option<Vec<Uuid>> = {
+        let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
+        match raw {
+            None => None,
+            Some(v) => {
+                let strings: Vec<String> = serde_json::from_value(v)
+                    .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
+                let uuids: std::result::Result<Vec<Uuid>, _> =
+                    strings.iter().map(|s| s.parse::<Uuid>()).collect();
+                Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
+            }
+        }
+    };
+
+    Ok(Some(crate::ApiTokenRecord {
+        id,
+        token_hash: row.try_get("token_hash")?,
+        owner_pubkey: row.try_get("owner_pubkey")?,
+        name: row.try_get("name")?,
+        scopes,
+        channel_ids,
+        created_at: row.try_get("created_at")?,
+        expires_at: row.try_get("expires_at")?,
+        last_used_at: row.try_get("last_used_at")?,
+        revoked_at: row.try_get("revoked_at")?,
+    }))
+}
+
+/// List all tokens (including revoked) for a pubkey, ordered by creation time descending.
+///
+/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
+/// responsible for stripping `token_hash` before returning data to clients — the
+/// raw token value is never exposed after the initial mint response.
+/// Used by `GET /api/tokens` to show a user their full token history.
+pub async fn list_tokens_by_owner(
+    pool: &MySqlPool,
+    pubkey: &[u8],
+) -> Result<Vec<crate::ApiTokenRecord>> {
+    let rows = sqlx::query(
+        r#"
+        SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
+               created_at, expires_at, last_used_at, revoked_at
+        FROM api_tokens
+        WHERE owner_pubkey = ?
+        ORDER BY created_at DESC
+        "#,
+    )
+    .bind(pubkey)
+    .fetch_all(pool)
+    .await?;
+
+    let mut out = Vec::with_capacity(rows.len());
+    for row in rows {
+        let id_bytes: Vec<u8> = row.try_get("id")?;
+        let id = uuid_from_bytes(&id_bytes)?;
+
+        let scopes_json: serde_json::Value = row.try_get("scopes")?;
+        let scopes: Vec<String> = serde_json::from_value(scopes_json)
+            .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
+
+        let channel_ids: Option<Vec<Uuid>> = {
+            let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
+            match raw {
+                None => None,
+                Some(v) => {
+                    let strings: Vec<String> = serde_json::from_value(v)
+                        .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
+                    let uuids: std::result::Result<Vec<Uuid>, _> =
+                        strings.iter().map(|s| s.parse::<Uuid>()).collect();
+                    Some(
+                        uuids
+                            .map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?,
+                    )
+                }
+            }
+        };
+
+        out.push(crate::ApiTokenRecord {
+            id,
+            token_hash: row.try_get("token_hash")?,
+            owner_pubkey: row.try_get("owner_pubkey")?,
+            name: row.try_get("name")?,
+            scopes,
+            channel_ids,
+            created_at: row.try_get("created_at")?,
+            expires_at: row.try_get("expires_at")?,
+            last_used_at: row.try_get("last_used_at")?,
+            revoked_at: row.try_get("revoked_at")?,
+        });
+    }
+    Ok(out)
+}
+
+/// Revoke a single token by ID, scoped to the owner.
+///
+/// Only revokes if the token is owned by `owner_pubkey` and not already revoked.
+/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
+pub async fn revoke_token(
+    pool: &MySqlPool,
+    id: Uuid,
+    owner_pubkey: &[u8],
+    revoked_by: &[u8],
+) -> Result<bool> {
+    let id_bytes = id.as_bytes().as_slice();
+    let result = sqlx::query(
+        r#"
+        UPDATE api_tokens
+        SET revoked_at = NOW(6), revoked_by = ?
+        WHERE id = ?
+          AND owner_pubkey = ?
+          AND revoked_at IS NULL
+        "#,
+    )
+    .bind(revoked_by)
+    .bind(id_bytes)
+    .bind(owner_pubkey)
+    .execute(pool)
+    .await?;
+
+    Ok(result.rows_affected() > 0)
+}
+
+/// Revoke all active tokens for a pubkey.
+///
+/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
+/// If all tokens are already revoked, returns 0 with no error.
+pub async fn revoke_all_tokens(
+    pool: &MySqlPool,
+    owner_pubkey: &[u8],
+    revoked_by: &[u8],
+) -> Result<u64> {
+    let result = sqlx::query(
+        r#"
+        UPDATE api_tokens
+        SET revoked_at = NOW(6), revoked_by = ?
+        WHERE owner_pubkey = ?
+          AND revoked_at IS NULL
+        "#,
+    )
+    .bind(revoked_by)
+    .bind(owner_pubkey)
+    .execute(pool)
+    .await?;
+
+    Ok(result.rows_affected())
+}
diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs
index 8697bd7fd9..266c771f7d 100644
--- a/crates/sprout-db/src/lib.rs
+++ b/crates/sprout-db/src/lib.rs
@@ -694,6 +694,68 @@ impl Db {
         .await
     }
 
+    /// Atomic conditional INSERT: create a self-minted token only if the owner has
+    /// fewer than 10 active (non-revoked, non-expired) tokens.
+    ///
+    /// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the limit is exceeded.
+    pub async fn create_api_token_if_under_limit(
+        &self,
+        token_hash: &[u8],
+        owner_pubkey: &[u8],
+        name: &str,
+        scopes: &[String],
+        channel_ids: Option<&[Uuid]>,
+        expires_at: Option<DateTime<Utc>>,
+    ) -> Result<Option<Uuid>> {
+        api_token::create_api_token_if_under_limit(
+            &self.pool,
+            token_hash,
+            owner_pubkey,
+            name,
+            scopes,
+            channel_ids,
+            expires_at,
+        )
+        .await
+    }
+
+    /// Look up an API token by its SHA-256 hash, **including revoked tokens**.
+    ///
+    /// Unlike [`Db::get_api_token_by_hash`], this does not filter on `revoked_at IS NULL`.
+    /// Used by the relay to return distinct `token_revoked` vs `invalid_token` errors.
+    pub async fn get_api_token_by_hash_including_revoked(
+        &self,
+        hash: &[u8],
+    ) -> Result<Option<ApiTokenRecord>> {
+        api_token::get_api_token_by_hash_including_revoked(&self.pool, hash).await
+    }
+
+    /// List all tokens (including revoked) for a pubkey, ordered by `created_at DESC`.
+    ///
+    /// Does not return `token_hash`. Used by `GET /api/tokens`.
+    pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result<Vec<ApiTokenRecord>> {
+        api_token::list_tokens_by_owner(&self.pool, pubkey).await
+    }
+
+    /// Revoke a single token by ID, scoped to the owner.
+    ///
+    /// Returns `true` if revoked, `false` if not found, not owned by caller, or already revoked.
+    pub async fn revoke_token(
+        &self,
+        id: Uuid,
+        owner_pubkey: &[u8],
+        revoked_by: &[u8],
+    ) -> Result<bool> {
+        api_token::revoke_token(&self.pool, id, owner_pubkey, revoked_by).await
+    }
+
+    /// Revoke all active tokens for a pubkey. Skips already-revoked tokens (idempotent).
+    ///
+    /// Returns the count of newly revoked tokens (0 if all already revoked).
+    pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result<u64> {
+        api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await
+    }
+
     /// List all non-revoked, non-expired API tokens.
     ///
     /// Returns a summary view — does not expose raw token hashes.
diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml
index ebfebc8cfd..d772bb77cd 100644
--- a/crates/sprout-relay/Cargo.toml
+++ b/crates/sprout-relay/Cargo.toml
@@ -43,6 +43,7 @@ serde_yaml      = { workspace = true }
 sha2            = { workspace = true }
 hex             = { workspace = true }
 url             = { workspace = true }
+moka            = { workspace = true }
 
 [dev-dependencies]
 sprout-core = { workspace = true, features = ["test-utils"] }
diff --git a/crates/sprout-relay/src/api/agents.rs b/crates/sprout-relay/src/api/agents.rs
index 1b0137434a..3f1f0191ef 100644
--- a/crates/sprout-relay/src/api/agents.rs
+++ b/crates/sprout-relay/src/api/agents.rs
@@ -13,7 +13,7 @@ use nostr::util::hex as nostr_hex;
 
 use crate::state::AppState;
 
-use super::{extract_auth_pubkey, internal_error};
+use super::{extract_auth_context, internal_error};
 
 /// Returns all bot/agent members visible to the authenticated user, with presence status.
 ///
@@ -22,7 +22,10 @@ pub async fn agents_handler(
     State(state): State<Arc<AppState>>,
     headers: HeaderMap,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     // Get requester's accessible channels to filter bot channel visibility.
     let accessible_channels = state
diff --git a/crates/sprout-relay/src/api/approvals.rs b/crates/sprout-relay/src/api/approvals.rs
index b7a2f6ea47..abef50e577 100644
--- a/crates/sprout-relay/src/api/approvals.rs
+++ b/crates/sprout-relay/src/api/approvals.rs
@@ -16,7 +16,7 @@ use serde::Deserialize;
 
 use crate::state::AppState;
 
-use super::{api_error, extract_auth_pubkey, forbidden, internal_error, not_found};
+use super::{api_error, extract_auth_context, forbidden, internal_error, not_found, scope_error};
 
 // ── Request body ──────────────────────────────────────────────────────────────
 
@@ -178,7 +178,10 @@ pub async fn grant_approval(
     Path(token): Path<String>,
     body: Option<Json<ApprovalBody>>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let approval = state
         .db
@@ -197,7 +200,7 @@ pub async fn grant_approval(
         return Err(api_error(StatusCode::GONE, "approval token has expired"));
     }
 
-    check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
+    check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
 
     let note = body.as_ref().and_then(|b| b.note.as_deref());
 
@@ -250,7 +253,10 @@ pub async fn deny_approval(
     Path(token): Path<String>,
     body: Option<Json<ApprovalBody>>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let approval = state
         .db
@@ -269,7 +275,7 @@ pub async fn deny_approval(
         return Err(api_error(StatusCode::GONE, "approval token has expired"));
     }
 
-    check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
+    check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
 
     let note = body.as_ref().and_then(|b| b.note.as_deref());
 
@@ -292,7 +298,7 @@ pub async fn deny_approval(
     // A run that has already transitioned to Failed/Completed/Cancelled through
     // another path (e.g., timeout, manual cancel) must not be overwritten.
     let run_id = approval.run_id;
-    let pubkey_for_msg = pubkey.to_hex();
+    let pubkey_for_msg = ctx.pubkey.to_hex();
     let db = state.db.clone();
     tokio::spawn(async move {
         let run = match db.get_workflow_run(run_id).await {
diff --git a/crates/sprout-relay/src/api/canvas.rs b/crates/sprout-relay/src/api/canvas.rs
index 617546056d..fa1007ee41 100644
--- a/crates/sprout-relay/src/api/canvas.rs
+++ b/crates/sprout-relay/src/api/canvas.rs
@@ -24,7 +24,10 @@ use sprout_db::event::EventQuery;
 use crate::handlers::event::dispatch_persistent_event;
 use crate::state::AppState;
 
-use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error};
+use super::{
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context,
+    internal_error,
+};
 
 // ── GET /api/channels/:channel_id/canvas ─────────────────────────────────────
 
@@ -37,11 +40,15 @@ pub async fn get_canvas(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     // Query the events table for the most recent KIND_CANVAS event scoped to
@@ -105,11 +112,15 @@ pub async fn set_canvas(
     Path(channel_id_str): Path<String>,
     Json(body): Json<SetCanvasBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     // Reject writes to archived channels (consistent with messages, metadata, etc.).
diff --git a/crates/sprout-relay/src/api/channels.rs b/crates/sprout-relay/src/api/channels.rs
index 394135a3de..c0668eeecf 100644
--- a/crates/sprout-relay/src/api/channels.rs
+++ b/crates/sprout-relay/src/api/channels.rs
@@ -19,7 +19,7 @@ use sprout_db::channel::{ChannelRecord, ChannelType, ChannelVisibility};
 
 use crate::state::AppState;
 
-use super::{api_error, extract_auth_pubkey, internal_error};
+use super::{api_error, extract_auth_context, internal_error};
 
 /// Query parameters for `GET /api/channels`.
 #[derive(Debug, Deserialize)]
@@ -36,7 +36,10 @@ pub async fn channels_handler(
     headers: HeaderMap,
     Query(params): Query<ListChannelsParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channels = state
         .db
@@ -101,7 +104,10 @@ pub async fn create_channel(
     headers: HeaderMap,
     ExtractJson(body): ExtractJson<CreateChannelBody>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let name = body.name.trim();
     if name.is_empty() {
diff --git a/crates/sprout-relay/src/api/channels_metadata.rs b/crates/sprout-relay/src/api/channels_metadata.rs
index 3b535572f4..e6db37d55e 100644
--- a/crates/sprout-relay/src/api/channels_metadata.rs
+++ b/crates/sprout-relay/src/api/channels_metadata.rs
@@ -34,7 +34,8 @@ use crate::handlers::side_effects::emit_system_message;
 use crate::state::AppState;
 
 use super::{
-    api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
+    internal_error, not_found, scope_error,
 };
 
 // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -99,8 +100,12 @@ pub async fn get_channel_handler(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
@@ -143,8 +148,12 @@ pub async fn update_channel_handler(
     Path(channel_id_str): Path<String>,
     ExtractJson(body): ExtractJson<UpdateChannelBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
 
@@ -237,8 +246,12 @@ pub async fn set_topic_handler(
     Path(channel_id_str): Path<String>,
     ExtractJson(body): ExtractJson<SetTopicBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
@@ -300,8 +313,12 @@ pub async fn set_purpose_handler(
     Path(channel_id_str): Path<String>,
     ExtractJson(body): ExtractJson<SetPurposeBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
@@ -359,8 +376,12 @@ pub async fn archive_channel_handler(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
 
@@ -400,8 +421,12 @@ pub async fn unarchive_channel_handler(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
 
@@ -441,8 +466,12 @@ pub async fn delete_channel_handler(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
     let channel_id = parse_channel_id(&channel_id_str)?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     // Only channel owners may delete.
     let role = state
diff --git a/crates/sprout-relay/src/api/dms.rs b/crates/sprout-relay/src/api/dms.rs
index bd014a509a..51e3ce0917 100644
--- a/crates/sprout-relay/src/api/dms.rs
+++ b/crates/sprout-relay/src/api/dms.rs
@@ -19,7 +19,7 @@ use uuid::Uuid;
 use crate::handlers::side_effects::emit_system_message;
 use crate::state::AppState;
 
-use super::{api_error, extract_auth_pubkey, internal_error};
+use super::{api_error, extract_auth_context, internal_error};
 
 // ── Request / query types ─────────────────────────────────────────────────────
 
@@ -58,7 +58,10 @@ pub async fn open_dm_handler(
     headers: HeaderMap,
     ExtractJson(body): ExtractJson<OpenDmBody>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let self_bytes = ctx.pubkey_bytes.clone();
 
     if body.pubkeys.is_empty() {
         return Err(api_error(
@@ -154,7 +157,10 @@ pub async fn add_dm_member_handler(
     Path(channel_id_str): Path<String>,
     ExtractJson(body): ExtractJson<AddDmMemberBody>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let self_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?;
@@ -258,7 +264,10 @@ pub async fn list_dms_handler(
     headers: HeaderMap,
     Query(params): Query<ListDmsQuery>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let self_bytes = ctx.pubkey_bytes.clone();
 
     let limit = params.limit.unwrap_or(50).min(200);
 
diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs
index 63de830dfa..5e609ebada 100644
--- a/crates/sprout-relay/src/api/events.rs
+++ b/crates/sprout-relay/src/api/events.rs
@@ -13,7 +13,10 @@ use axum::{
 
 use crate::state::AppState;
 
-use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
+use super::{
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context,
+    internal_error, not_found,
+};
 
 /// Fetch a single stored event by its 64-char hex ID.
 pub async fn get_event(
@@ -21,7 +24,10 @@ pub async fn get_event(
     headers: HeaderMap,
     Path(event_id): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id_bytes = hex::decode(&event_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?;
@@ -37,6 +43,9 @@ pub async fn get_event(
         .ok_or_else(|| not_found("event not found"))?;
 
     if let Some(channel_id) = stored_event.channel_id {
+        // Token-level channel restriction check (in addition to membership check).
+        // channel_id is obtained from the event's stored metadata — no extra lookup needed.
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     } else {
         return Err(not_found("event not found"));
diff --git a/crates/sprout-relay/src/api/feed.rs b/crates/sprout-relay/src/api/feed.rs
index 2630e2a7a6..21b9e9581b 100644
--- a/crates/sprout-relay/src/api/feed.rs
+++ b/crates/sprout-relay/src/api/feed.rs
@@ -21,7 +21,7 @@ use sprout_core::kind::{self, event_kind_u32};
 
 use crate::state::AppState;
 
-use super::{extract_auth_pubkey, internal_error};
+use super::{extract_auth_context, internal_error};
 
 /// Agent activity kind set — used to partition activity into agent vs channel activity.
 const AGENT_KINDS: &[u32] = &[
@@ -54,7 +54,10 @@ pub async fn feed_handler(
     headers: HeaderMap,
     Query(params): Query<FeedParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let limit = params.limit.unwrap_or(20).min(50) as i64;
     let since: DateTime<Utc> = params
diff --git a/crates/sprout-relay/src/api/members.rs b/crates/sprout-relay/src/api/members.rs
index 2fd7dbd693..f81d9aa3a9 100644
--- a/crates/sprout-relay/src/api/members.rs
+++ b/crates/sprout-relay/src/api/members.rs
@@ -24,7 +24,10 @@ use uuid::Uuid;
 use crate::handlers::side_effects::emit_system_message;
 use crate::state::AppState;
 
-use super::{api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error};
+use super::{
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
+    internal_error, scope_error,
+};
 
 // ── Helpers ───────────────────────────────────────────────────────────────────
 
@@ -72,10 +75,14 @@ pub async fn add_members(
     Path(channel_id): Path<String>,
     ExtractJson(body): ExtractJson<AddMembersBody>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
+        .map_err(scope_error)?;
+    let actor_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     // Private channels require owner/admin to add members.
     let channel = state
@@ -245,10 +252,14 @@ pub async fn remove_member(
     headers: HeaderMap,
     Path((channel_id, pubkey)): Path<(String, String)>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_actor_pk, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
+        .map_err(scope_error)?;
+    let actor_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     let target_bytes = hex::decode(&pubkey)
         .ok()
@@ -330,10 +341,14 @@ pub async fn list_members(
     headers: HeaderMap,
     Path(channel_id): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
+    check_token_channel_access(&ctx, &channel_id)?;
 
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
@@ -390,7 +405,10 @@ pub async fn join_channel(
     headers: HeaderMap,
     Path(channel_id): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
@@ -449,7 +467,10 @@ pub async fn leave_channel(
     headers: HeaderMap,
     Path(channel_id): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = Uuid::parse_str(&channel_id)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs
index acb6f0c166..9e5bff4047 100644
--- a/crates/sprout-relay/src/api/messages.rs
+++ b/crates/sprout-relay/src/api/messages.rs
@@ -31,7 +31,8 @@ use crate::handlers::event::dispatch_persistent_event;
 use crate::state::AppState;
 
 use super::{
-    api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
+    internal_error, not_found,
 };
 
 /// Extract the effective message author from a stored event.
@@ -281,11 +282,15 @@ pub async fn send_message(
     Path(channel_id_str): Path<String>,
     Json(body): Json<SendMessageBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     let channel = state
@@ -499,7 +504,10 @@ pub async fn delete_message(
     headers: HeaderMap,
     Path(event_id_hex): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let event_id_bytes = nostr_hex::decode(&event_id_hex)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
@@ -516,6 +524,9 @@ pub async fn delete_message(
         .channel_id
         .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
 
+    // Token-level channel restriction check (channel_id from event lookup).
+    check_token_channel_access(&ctx, &channel_id)?;
+
     // Auth: must be the message author OR an owner/admin of the channel.
     // For relay-signed REST messages, the real author is in the p tag.
     let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
@@ -589,11 +600,15 @@ pub async fn list_messages(
     Path(channel_id_str): Path<String>,
     Query(params): Query<ListMessagesParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     let limit = params.limit.unwrap_or(50).min(200);
@@ -704,11 +719,15 @@ pub async fn get_thread(
     Path((channel_id_str, event_id_hex)): Path<(String, String)>,
     Query(params): Query<GetThreadParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     let root_id_bytes = nostr_hex::decode(&event_id_hex)
diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs
index 8af69959be..b15b88cca6 100644
--- a/crates/sprout-relay/src/api/mod.rs
+++ b/crates/sprout-relay/src/api/mod.rs
@@ -38,6 +38,8 @@ pub mod presence;
 pub mod reactions;
 /// Full-text search endpoint.
 pub mod search;
+/// Self-service API token minting, listing, and revocation endpoints.
+pub mod tokens;
 /// User profile endpoints.
 pub mod users;
 /// Shared helpers for workflow API handlers.
@@ -73,91 +75,157 @@ pub use workflows::{
 // ── Shared helpers ────────────────────────────────────────────────────────────
 
 use std::collections::HashMap;
+use std::time::{Duration, Instant};
 
 use axum::{
     http::{HeaderMap, StatusCode},
     response::Json,
 };
 use sha2::{Digest, Sha256};
+use uuid::Uuid;
 
-use crate::state::AppState;
-
-/// Standard error envelope.
-pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json<serde_json::Value>) {
-    (status, Json(serde_json::json!({ "error": msg })))
-}
-
-pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
-    tracing::error!("Internal error: {msg}");
-    api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
-}
+use sprout_auth::Scope;
 
-pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
-    api_error(StatusCode::NOT_FOUND, msg)
-}
+use crate::state::AppState;
 
-pub(crate) fn forbidden(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
-    api_error(StatusCode::FORBIDDEN, msg)
+// ── Auth context types ────────────────────────────────────────────────────────
+
+/// How the REST request was authenticated.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RestAuthMethod {
+    /// `Authorization: Bearer sprout_*` — API token verified against DB hash.
+    ApiToken,
+    /// `Authorization: Bearer eyJ*` — Okta JWT validated via JWKS.
+    OktaJwt,
+    /// `Authorization: Nostr <base64>` — NIP-98 HTTP Auth (bootstrap path only).
+    Nip98,
+    /// `X-Pubkey: <hex>` — dev mode only (`require_auth_token=false`).
+    DevPubkey,
 }
 
-/// Decode a JWT payload segment without signature verification.
-/// Used in dev mode (`require_auth_token=false`) to extract `preferred_username`.
-fn decode_jwt_payload_unverified(
-    token: &str,
-) -> Result<HashMap<String, serde_json::Value>, String> {
-    use base64::Engine as _;
-    let parts: Vec<&str> = token.split('.').collect();
-    if parts.len() < 2 {
-        return Err("malformed JWT".into());
-    }
-    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
-        .decode(parts[1])
-        .map_err(|_| "authentication failed".to_string())?;
-    serde_json::from_slice(&decoded).map_err(|_| "authentication failed".to_string())
+/// Full authentication context returned to REST handlers.
+///
+/// Replaces the old `(pubkey, pubkey_bytes)` tuple from `extract_auth_pubkey`.
+/// The `pubkey` and `pubkey_bytes` fields are identical to the old return value,
+/// so existing handler logic is unchanged; scope and channel checks can now be
+/// layered on top.
+#[derive(Debug, Clone)]
+pub struct RestAuthContext {
+    /// The authenticated Nostr public key.
+    pub pubkey: nostr::PublicKey,
+    /// Compressed (32-byte) serialisation of `pubkey`.
+    pub pubkey_bytes: Vec<u8>,
+    /// Permission scopes granted to this request.
+    ///
+    /// Empty for the NIP-98 bootstrap path — the caller must be `POST /api/tokens`.
+    pub scopes: Vec<Scope>,
+    /// How the request was authenticated.
+    pub auth_method: RestAuthMethod,
+    /// The UUID of the API token used, if auth_method is `ApiToken`.
+    pub token_id: Option<Uuid>,
+    /// Token-level channel restriction, if any.
+    ///
+    /// `None` means unrestricted (all channels the pubkey is a member of).
+    /// `Some([])` means no channels are permitted.
+    pub channel_ids: Option<Vec<Uuid>>,
 }
 
-/// Extract an authenticated pubkey from the request headers.
+/// Extract the full auth context from request headers.
 ///
 /// Auth resolution order:
-/// 1. `Authorization: Bearer sprout_*` — API token; hashed, looked up in DB.
-/// 2. `Authorization: Bearer eyJ*` — Okta JWT; validated via JWKS when
-///    `require_auth_token=true`, or decoded unverified (username → derived key) when
-///    `require_auth_token=false`.
-/// 3. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
+/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here
+/// 2. `Authorization: Bearer eyJ*` — Okta JWT
+/// 3. `X-Pubkey: <hex>` — dev mode only
+///
+/// NIP-98 (`Authorization: Nostr <base64>`) is **not** handled here — it is only
+/// valid for `POST /api/tokens` and is verified directly in that handler. Any
+/// request that sends a `Nostr` auth header to a non-token endpoint will receive
+/// a 401 with `"nip98_not_supported"`.
 ///
-/// Returns `(nostr::PublicKey, pubkey_bytes)` on success, or a 401 response on failure.
-pub(crate) async fn extract_auth_pubkey(
+/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure.
+pub(crate) async fn extract_auth_context(
     headers: &HeaderMap,
     state: &AppState,
-) -> Result<(nostr::PublicKey, Vec<u8>), (StatusCode, Json<serde_json::Value>)> {
+) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
     let require_auth = state.config.require_auth_token;
 
-    // Try Authorization: Bearer <token>
     if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
+        // ── 1. Reject NIP-98 on non-token endpoints ───────────────────────────
+        // NIP-98 auth is only valid for POST /api/tokens (handled directly in
+        // post_tokens). Sending it here is a client error — reject explicitly
+        // rather than falling through to a confusing "authentication failed".
+        if auth_header.starts_with("Nostr ") {
+            tracing::warn!("auth: NIP-98 auth header sent to non-token endpoint");
+            return Err((
+                StatusCode::UNAUTHORIZED,
+                Json(serde_json::json!({
+                    "error": "nip98_not_supported",
+                    "message": "NIP-98 auth is only supported for POST /api/tokens"
+                })),
+            ));
+        }
+
+        // ── 2. Bearer token path ──────────────────────────────────────────────
         if let Some(token) = auth_header.strip_prefix("Bearer ") {
-            // ── API token path (sprout_*) ─────────────────────────────────
-            // Must be checked before the Okta JWT path: verify_auth_event()
-            // (and validate_bearer_jwt) would reject sprout_ tokens immediately.
+            // ── 2a. API token (sprout_*) ──────────────────────────────────────
             if token.starts_with("sprout_") {
                 let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
-                let record = match state.db.get_api_token_by_hash(&hash).await {
-                    Ok(r) => r,
+
+                // Use the new including-revoked query so we can return distinct
+                // token_revoked vs invalid_token errors.
+                let record = match state
+                    .db
+                    .get_api_token_by_hash_including_revoked(&hash)
+                    .await
+                {
+                    Ok(Some(r)) => r,
+                    Ok(None) => {
+                        tracing::warn!("auth: API token not found");
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "invalid_token" })),
+                        ));
+                    }
                     Err(e) => {
                         tracing::warn!("auth: API token lookup failed: {e}");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "invalid_token" })),
+                        ));
                     }
                 };
+
+                // Relay-layer revocation check (before hash verification).
+                if record.revoked_at.is_some() {
+                    tracing::warn!("auth: API token is revoked");
+                    return Err((
+                        StatusCode::UNAUTHORIZED,
+                        Json(serde_json::json!({ "error": "token_revoked" })),
+                    ));
+                }
+
+                // Relay-layer expiry check (before hash verification).
+                if let Some(exp) = record.expires_at {
+                    if exp < chrono::Utc::now() {
+                        tracing::warn!("auth: API token is expired");
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "token_expired" })),
+                        ));
+                    }
+                }
+
                 let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
                     Ok(pk) => pk,
                     Err(e) => {
                         tracing::warn!("auth: API token owner pubkey invalid: {e}");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "invalid_token" })),
+                        ));
                     }
                 };
-                // verify_api_token_against_hash checks hash match, expiry, and
-                // pubkey equality. For the REST path there is no "claimed" pubkey
-                // from a Nostr event, so we pass the owner pubkey for both
-                // arguments — we are asserting identity from the DB record itself.
+
                 match state.auth.verify_api_token_against_hash(
                     token,
                     &record.token_hash,
@@ -166,39 +234,69 @@ pub(crate) async fn extract_auth_pubkey(
                     record.expires_at,
                     &record.scopes,
                 ) {
-                    Ok((pubkey, _scopes)) => {
-                        let bytes = pubkey.serialize().to_vec();
-                        if let Err(e) = state.db.ensure_user(&bytes).await {
+                    Ok((pubkey, scopes)) => {
+                        let pubkey_bytes = pubkey.serialize().to_vec();
+                        if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
                             tracing::warn!("ensure_user failed: {e}");
                         }
-                        // Update last_used_at — non-fatal.
-                        let _ = state.db.update_token_last_used(&hash).await;
-                        return Ok((pubkey, bytes));
+
+                        // Debounced last_used_at update — at most once per 5 min per token.
+                        let should_update = state
+                            .last_used_cache
+                            .get(&record.id)
+                            .map(|t| t.elapsed() > Duration::from_secs(300))
+                            .unwrap_or(true);
+                        if should_update {
+                            state.last_used_cache.insert(record.id, Instant::now());
+                            let db = state.db.clone();
+                            let hash_copy = hash;
+                            tokio::spawn(async move {
+                                let _ = db.update_token_last_used(&hash_copy).await;
+                            });
+                        }
+
+                        return Ok(RestAuthContext {
+                            pubkey,
+                            pubkey_bytes,
+                            scopes,
+                            auth_method: RestAuthMethod::ApiToken,
+                            token_id: Some(record.id),
+                            channel_ids: record.channel_ids,
+                        });
                     }
                     Err(_) => {
-                        tracing::warn!("auth: API token verification failed");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        tracing::warn!("auth: API token hash verification failed");
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "invalid_token" })),
+                        ));
                     }
                 }
             }
 
+            // ── 2b. Okta JWT (eyJ*) ───────────────────────────────────────────
             if require_auth {
-                // Production: validate JWT against JWKS
                 match state.auth.validate_bearer_jwt(token).await {
-                    // NOTE: Scope enforcement is deferred to a future milestone.
-                    // Currently all authenticated users get full API access.
-                    Ok((pubkey, _scopes)) => {
-                        let bytes = pubkey.serialize().to_vec();
-                        // Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
-                        if let Err(e) = state.db.ensure_user(&bytes).await {
+                    Ok((pubkey, scopes)) => {
+                        let pubkey_bytes = pubkey.serialize().to_vec();
+                        if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
                             tracing::warn!("ensure_user failed: {e}");
-                            // Non-fatal — don't block auth if user creation fails
                         }
-                        return Ok((pubkey, bytes));
+                        return Ok(RestAuthContext {
+                            pubkey,
+                            pubkey_bytes,
+                            scopes,
+                            auth_method: RestAuthMethod::OktaJwt,
+                            token_id: None,
+                            channel_ids: None,
+                        });
                     }
                     Err(_) => {
                         tracing::warn!("auth: JWT validation failed");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "authentication failed" })),
+                        ));
                     }
                 }
             } else {
@@ -210,67 +308,177 @@ pub(crate) async fn extract_auth_pubkey(
                         {
                             match sprout_auth::derive_pubkey_from_username(username) {
                                 Ok(pubkey) => {
-                                    let bytes = pubkey.serialize().to_vec();
-                                    // Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
-                                    if let Err(e) = state.db.ensure_user(&bytes).await {
+                                    let pubkey_bytes = pubkey.serialize().to_vec();
+                                    if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
                                         tracing::warn!("ensure_user failed: {e}");
-                                        // Non-fatal — don't block auth if user creation fails
                                     }
-                                    return Ok((pubkey, bytes));
+                                    return Ok(RestAuthContext {
+                                        pubkey,
+                                        pubkey_bytes,
+                                        scopes: vec![Scope::MessagesRead, Scope::MessagesWrite],
+                                        auth_method: RestAuthMethod::OktaJwt,
+                                        token_id: None,
+                                        channel_ids: None,
+                                    });
                                 }
                                 Err(_) => {
                                     tracing::warn!("auth: key derivation failed for username");
-                                    return Err(api_error(
+                                    return Err((
                                         StatusCode::UNAUTHORIZED,
-                                        "authentication failed",
+                                        Json(
+                                            serde_json::json!({ "error": "authentication failed" }),
+                                        ),
                                     ));
                                 }
                             }
                         }
-                        // JWT present but no preferred_username — fail, don't silently downgrade
                         tracing::warn!("auth: JWT missing preferred_username claim");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "authentication failed" })),
+                        ));
                     }
                     Err(_) => {
-                        // Malformed JWT — fail, don't silently downgrade to X-Pubkey
                         tracing::warn!("auth: malformed JWT");
-                        return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({ "error": "authentication failed" })),
+                        ));
                     }
                 }
             }
         }
     }
 
-    // Dev fallback: X-Pubkey header (only when require_auth_token=false)
+    // ── 4. Dev fallback: X-Pubkey ─────────────────────────────────────────────
     if !require_auth {
         if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
             match nostr::PublicKey::from_hex(hex_val) {
                 Ok(pubkey) => {
-                    let bytes = pubkey.serialize().to_vec();
-                    // Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
-                    if let Err(e) = state.db.ensure_user(&bytes).await {
+                    let pubkey_bytes = pubkey.serialize().to_vec();
+                    if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
                         tracing::warn!("ensure_user failed: {e}");
-                        // Non-fatal — don't block auth if user creation fails
                     }
-                    return Ok((pubkey, bytes));
+                    // Dev mode grants all scopes (including admin) — it's a development convenience.
+                    // Production deployments MUST set SPROUT_REQUIRE_AUTH_TOKEN=true.
+                    return Ok(RestAuthContext {
+                        pubkey,
+                        pubkey_bytes,
+                        scopes: Scope::all_known(),
+                        auth_method: RestAuthMethod::DevPubkey,
+                        token_id: None,
+                        channel_ids: None,
+                    });
                 }
                 Err(_) => {
                     tracing::warn!("auth: invalid X-Pubkey header value");
-                    return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
+                    return Err((
+                        StatusCode::UNAUTHORIZED,
+                        Json(serde_json::json!({ "error": "authentication failed" })),
+                    ));
                 }
             }
         }
     }
 
-    Err(api_error(
+    Err((
         StatusCode::UNAUTHORIZED,
-        "authentication required",
+        Json(serde_json::json!({ "error": "authentication required" })),
     ))
 }
 
-/// Check channel access: member OR open-visibility channel.
+// ── Step 8: Token-level channel access enforcement ────────────────────────────
+
+/// Check whether a token is permitted to access the given channel.
+///
+/// This is a **token-level** check — it verifies that `channel_id` is in the
+/// token's `channel_ids` restriction list. It is **in addition to** the
+/// membership check (`check_channel_access`) and scope check (`require_scope`);
+/// all three must pass.
+///
+/// Tokens with `channel_ids = None` (no restriction) always pass this check.
+/// Tokens with `channel_ids = Some([])` (empty list) deny all channels.
+pub fn check_token_channel_access(
+    ctx: &RestAuthContext,
+    channel_id: &Uuid,
+) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
+    if let Some(ref allowed) = ctx.channel_ids {
+        if !allowed.contains(channel_id) {
+            return Err((
+                StatusCode::FORBIDDEN,
+                Json(serde_json::json!({
+                    "error": "channel_not_permitted",
+                    "message": "Token does not have access to this channel"
+                })),
+            ));
+        }
+    }
+    Ok(())
+}
+
+/// Convert a scope-check failure into a 403 Forbidden response.
+///
+/// Used by handlers to propagate `require_scope` errors via `?`.
+pub(crate) fn scope_error(e: sprout_auth::AuthError) -> (StatusCode, Json<serde_json::Value>) {
+    match e {
+        sprout_auth::AuthError::InsufficientScope { required, .. } => (
+            StatusCode::FORBIDDEN,
+            Json(serde_json::json!({
+                "error": "insufficient_scope",
+                "message": format!("token missing required scope: {required}"),
+            })),
+        ),
+        other => {
+            tracing::warn!("scope_error: unexpected auth error: {other}");
+            api_error(StatusCode::FORBIDDEN, "insufficient_scope")
+        }
+    }
+}
+
+/// Standard error envelope.
+pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json<serde_json::Value>) {
+    (status, Json(serde_json::json!({ "error": msg })))
+}
+
+pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
+    tracing::error!("Internal error: {msg}");
+    api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
+}
+
+pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
+    api_error(StatusCode::NOT_FOUND, msg)
+}
+
+pub(crate) fn forbidden(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
+    api_error(StatusCode::FORBIDDEN, msg)
+}
+
+/// Decode a JWT payload segment without signature verification.
+/// Used in dev mode (`require_auth_token=false`) to extract `preferred_username`.
+fn decode_jwt_payload_unverified(
+    token: &str,
+) -> Result<HashMap<String, serde_json::Value>, String> {
+    use base64::Engine as _;
+    let parts: Vec<&str> = token.split('.').collect();
+    if parts.len() < 2 {
+        return Err("malformed JWT".into());
+    }
+    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
+        .decode(parts[1])
+        .map_err(|_| "authentication failed".to_string())?;
+    serde_json::from_slice(&decoded).map_err(|_| "authentication failed".to_string())
+}
+
+/// Check channel membership access: member OR open-visibility channel.
+///
 /// Open channels (visibility = "open") allow any authenticated user to read/write.
-pub(crate) async fn check_channel_access(
+/// This is the **membership** check — separate from the token-level channel restriction
+/// check ([`check_token_channel_access`]).
+///
+/// # Note
+/// This function is also exported as [`check_channel_access`] for backward compatibility
+/// while Step 7 migrates all handlers to use [`extract_auth_context`].
+pub(crate) async fn check_channel_membership(
     state: &AppState,
     channel_id: uuid::Uuid,
     pubkey_bytes: &[u8],
@@ -296,6 +504,18 @@ pub(crate) async fn check_channel_access(
     }
 }
 
+/// Backward-compatible alias for [`check_channel_membership`].
+///
+/// Step 7 will replace all call sites with `check_channel_membership` and remove this alias.
+#[allow(dead_code)]
+pub(crate) async fn check_channel_access(
+    state: &AppState,
+    channel_id: uuid::Uuid,
+    pubkey_bytes: &[u8],
+) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
+    check_channel_membership(state, channel_id, pubkey_bytes).await
+}
+
 // ── Custom JSON extractor ─────────────────────────────────────────────────────
 
 use axum::extract::{rejection::JsonRejection, FromRequest, Request};
diff --git a/crates/sprout-relay/src/api/presence.rs b/crates/sprout-relay/src/api/presence.rs
index 3e72853a3d..05d5859106 100644
--- a/crates/sprout-relay/src/api/presence.rs
+++ b/crates/sprout-relay/src/api/presence.rs
@@ -13,7 +13,7 @@ use sprout_pubsub::presence::PRESENCE_TTL_SECS;
 
 use crate::state::AppState;
 
-use super::extract_auth_pubkey;
+use super::extract_auth_context;
 
 /// Query parameters for the presence endpoint.
 #[derive(Debug, Deserialize)]
@@ -31,7 +31,9 @@ pub async fn presence_handler(
     headers: HeaderMap,
     Query(params): Query<PresenceParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(super::scope_error)?;
 
     let pubkeys_param = params.pubkeys.unwrap_or_default();
 
@@ -91,7 +93,10 @@ pub async fn set_presence_handler(
     headers: HeaderMap,
     super::ApiJson(body): super::ApiJson<SetPresenceBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(super::scope_error)?;
+    let pubkey = ctx.pubkey;
 
     match body.status {
         PresenceStatus::Online | PresenceStatus::Away => {
diff --git a/crates/sprout-relay/src/api/reactions.rs b/crates/sprout-relay/src/api/reactions.rs
index 5bf85d028a..3de4b05f0c 100644
--- a/crates/sprout-relay/src/api/reactions.rs
+++ b/crates/sprout-relay/src/api/reactions.rs
@@ -26,7 +26,10 @@ use serde::Deserialize;
 
 use crate::state::AppState;
 
-use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
+use super::{
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context,
+    internal_error, not_found,
+};
 
 // ── Request / query types ─────────────────────────────────────────────────────
 
@@ -78,7 +81,10 @@ pub async fn add_reaction_handler(
     Path(event_id_hex): Path<String>,
     axum::extract::Json(body): axum::extract::Json<AddReactionBody>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let emoji = body.emoji.trim().to_string();
     if emoji.is_empty() {
@@ -97,6 +103,8 @@ pub async fn add_reaction_handler(
 
     // Verify channel access if the event belongs to a channel.
     if let Some(channel_id) = stored.channel_id {
+        // Token-level channel restriction check (channel_id from event lookup).
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
         let channel = state
             .db
@@ -142,7 +150,10 @@ pub async fn remove_reaction_handler(
     headers: HeaderMap,
     Path((event_id_hex, emoji)): Path<(String, String)>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let event_id_bytes = decode_event_id(&event_id_hex)?;
 
@@ -156,6 +167,8 @@ pub async fn remove_reaction_handler(
 
     // Verify channel access if the event belongs to a channel.
     if let Some(channel_id) = stored.channel_id {
+        // Token-level channel restriction check (channel_id from event lookup).
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
         let channel = state
             .db
@@ -197,7 +210,10 @@ pub async fn list_reactions_handler(
     Path(event_id_hex): Path<String>,
     Query(params): Query<ListReactionsParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let limit = params.limit.unwrap_or(50).min(200);
     let cursor = params.cursor.as_deref();
@@ -214,6 +230,8 @@ pub async fn list_reactions_handler(
 
     // Verify channel access if the event belongs to a channel.
     if let Some(channel_id) = stored.channel_id {
+        // Token-level channel restriction check (channel_id from event lookup).
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     }
 
diff --git a/crates/sprout-relay/src/api/search.rs b/crates/sprout-relay/src/api/search.rs
index d0050f065a..9f4db51c16 100644
--- a/crates/sprout-relay/src/api/search.rs
+++ b/crates/sprout-relay/src/api/search.rs
@@ -14,7 +14,7 @@ use sprout_search::SearchQuery;
 
 use crate::state::AppState;
 
-use super::extract_auth_pubkey;
+use super::extract_auth_context;
 
 /// Query parameters for the search endpoint.
 #[derive(Debug, Deserialize)]
@@ -34,7 +34,10 @@ pub async fn search_handler(
     headers: HeaderMap,
     Query(params): Query<SearchParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
+        .map_err(super::scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let query_str = params.q.unwrap_or_default();
     let per_page = params.limit.unwrap_or(20).min(100);
diff --git a/crates/sprout-relay/src/api/tokens.rs b/crates/sprout-relay/src/api/tokens.rs
new file mode 100644
index 0000000000..687b107463
--- /dev/null
+++ b/crates/sprout-relay/src/api/tokens.rs
@@ -0,0 +1,792 @@
+//! Self-service API token minting, listing, and revocation endpoints.
+//!
+//! ## Routes
+//! - `POST   /api/tokens`      — mint a new token (NIP-98 bootstrap or Bearer)
+//! - `GET    /api/tokens`      — list own tokens (Bearer required)
+//! - `DELETE /api/tokens/{id}` — revoke one token by UUID (Bearer required)
+//! - `DELETE /api/tokens`      — revoke all tokens / panic button (Bearer required)
+//!
+//! ## Rate Limiting
+//! [`MintRateLimiter`] enforces a configurable per-pubkey-per-hour limit
+//! (default 50, override with `SPROUT_MINT_RATE_LIMIT`) using a bounded
+//! in-memory rolling-window cache (moka). Resets on restart — acceptable since
+//! the limit is a DoS guard, not a hard security cap.
+
+use std::collections::VecDeque;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use axum::{
+    extract::{Path, State},
+    http::{HeaderMap, StatusCode},
+    response::Json,
+};
+use chrono::Utc;
+use moka::sync::Cache;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use uuid::Uuid;
+
+use nostr::JsonUtil;
+use sprout_auth::{is_self_mintable, Scope};
+
+use super::{api_error, extract_auth_context, internal_error, RestAuthMethod};
+use crate::state::AppState;
+
+// ── Rate limiter ──────────────────────────────────────────────────────────────
+
+/// Default: 50 mints per pubkey per hour. Override with `SPROUT_MINT_RATE_LIMIT`.
+const DEFAULT_MINT_LIMIT: usize = 50;
+const MINT_WINDOW: Duration = Duration::from_secs(3600);
+const RATE_LIMITER_MAX_ENTRIES: u64 = 100_000;
+
+/// Per-pubkey rolling-window rate limiter for `POST /api/tokens`.
+///
+/// Uses a bounded moka cache (max 100,000 entries) to prevent OOM from an
+/// attacker flooding with unique pubkeys. Evicted entries lose their window
+/// history — worst case is a few extra mints, not a security bypass.
+pub struct MintRateLimiter {
+    cache: Cache<[u8; 32], Arc<std::sync::Mutex<VecDeque<Instant>>>>,
+    limit: usize,
+}
+
+impl MintRateLimiter {
+    /// Create a new rate limiter.
+    ///
+    /// `limit` is the max mints per pubkey per hour. Reads `SPROUT_MINT_RATE_LIMIT`
+    /// env var at construction; falls back to [`DEFAULT_MINT_LIMIT`] (50).
+    pub fn new() -> Self {
+        let limit = std::env::var("SPROUT_MINT_RATE_LIMIT")
+            .ok()
+            .and_then(|v| v.parse::<usize>().ok())
+            .unwrap_or(DEFAULT_MINT_LIMIT);
+        tracing::info!("mint rate limiter: {limit} mints/hr/pubkey");
+        Self {
+            cache: Cache::builder()
+                .max_capacity(RATE_LIMITER_MAX_ENTRIES)
+                .time_to_idle(Duration::from_secs(3600))
+                .build(),
+            limit,
+        }
+    }
+
+    /// Check whether `pubkey_bytes` is within the rate limit and record the attempt.
+    ///
+    /// Returns `Ok(())` if the mint is allowed, or `Err(retry_after)` with the
+    /// duration until the oldest entry in the window expires.
+    pub fn check_and_record(&self, pubkey_bytes: &[u8; 32]) -> Result<(), Duration> {
+        let now = Instant::now();
+        let entry = self
+            .cache
+            .entry(*pubkey_bytes)
+            .or_insert_with(|| Arc::new(std::sync::Mutex::new(VecDeque::new())));
+        let mut timestamps = entry.value().lock().unwrap();
+
+        // Evict timestamps that have fallen outside the rolling window.
+        while timestamps
+            .front()
+            .map(|t| now.duration_since(*t) > MINT_WINDOW)
+            .unwrap_or(false)
+        {
+            timestamps.pop_front();
+        }
+
+        if timestamps.len() >= self.limit {
+            let retry_after = MINT_WINDOW - now.duration_since(*timestamps.front().unwrap());
+            return Err(retry_after);
+        }
+
+        timestamps.push_back(now);
+        Ok(())
+    }
+
+    /// The configured limit (for error messages).
+    pub fn limit(&self) -> usize {
+        self.limit
+    }
+}
+
+impl Default for MintRateLimiter {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+// ── Request / response types ──────────────────────────────────────────────────
+
+/// Request body for `POST /api/tokens`.
+#[derive(Debug, Deserialize)]
+pub struct MintTokenRequest {
+    /// Human-readable label for the token (1–100 chars).
+    pub name: String,
+    /// Scope strings to grant (e.g. `["messages:read", "channels:read"]`).
+    pub scopes: Vec<String>,
+    /// Optional channel UUIDs to restrict the token to.
+    /// Absent means unrestricted (unless caller's token is channel-restricted, in which case
+    /// channel_ids is required). Empty array is rejected for restricted callers.
+    pub channel_ids: Option<Vec<String>>,
+    /// Optional expiry in days (1–365). Omit for no expiry.
+    pub expires_in_days: Option<u32>,
+}
+
+/// Response body for `POST /api/tokens` (token shown once only).
+#[derive(Debug, Serialize)]
+pub struct MintTokenResponse {
+    /// Unique token identifier (UUID).
+    pub id: Uuid,
+    /// Raw token value — shown **once only**. Only the SHA-256 hash is stored.
+    pub token: String,
+    /// Human-readable label.
+    pub name: String,
+    /// Scope strings granted to this token.
+    pub scopes: Vec<String>,
+    /// Channel UUIDs this token is restricted to (empty = unrestricted).
+    pub channel_ids: Vec<String>,
+    /// ISO 8601 creation timestamp.
+    pub created_at: String,
+    /// ISO 8601 expiry timestamp, or `null` if no expiry.
+    pub expires_at: Option<String>,
+}
+
+/// A single token entry in the `GET /api/tokens` response.
+#[derive(Debug, Serialize)]
+pub struct TokenListItem {
+    /// Unique token identifier (UUID).
+    pub id: Uuid,
+    /// Human-readable label.
+    pub name: String,
+    /// Scope strings granted to this token.
+    pub scopes: Vec<String>,
+    /// Channel UUIDs this token is restricted to (empty = unrestricted).
+    pub channel_ids: Vec<String>,
+    /// ISO 8601 creation timestamp.
+    pub created_at: String,
+    /// ISO 8601 expiry timestamp, or `null` if no expiry.
+    pub expires_at: Option<String>,
+    /// ISO 8601 timestamp of last use, or `null` if never used.
+    pub last_used_at: Option<String>,
+    /// ISO 8601 revocation timestamp, or `null` if not revoked.
+    pub revoked_at: Option<String>,
+}
+
+/// Response body for `GET /api/tokens`.
+#[derive(Debug, Serialize)]
+pub struct TokenListResponse {
+    /// All tokens owned by the authenticated pubkey (including revoked).
+    pub tokens: Vec<TokenListItem>,
+}
+
+/// Response body for `DELETE /api/tokens` (panic button).
+#[derive(Debug, Serialize)]
+pub struct RevokeAllResponse {
+    /// Number of tokens that were newly revoked (0 if all already revoked).
+    pub revoked_count: u64,
+}
+
+// ── Handlers ──────────────────────────────────────────────────────────────────
+
+/// `POST /api/tokens` — mint a new API token.
+///
+/// Accepts NIP-98 HTTP Auth (bootstrap — no existing token required) or an
+/// existing Bearer API token. Validates scopes, rate-limits, checks channel
+/// membership if `channel_ids` is provided, then inserts via the conditional
+/// INSERT that enforces the 10-token-per-pubkey limit atomically.
+pub async fn post_tokens(
+    State(state): State<std::sync::Arc<AppState>>,
+    headers: HeaderMap,
+    body: axum::body::Bytes,
+) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
+    // Parse the request body as JSON.
+    let req: MintTokenRequest = serde_json::from_slice(&body).map_err(|e| {
+        api_error(
+            StatusCode::BAD_REQUEST,
+            &format!("invalid request body: {e}"),
+        )
+    })?;
+
+    // Extract auth context — NIP-98 or Bearer.
+    // For NIP-98 we re-verify here with the raw body for payload hash checking.
+    let ctx = if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok())
+    {
+        if let Some(encoded) = auth_header.strip_prefix("Nostr ") {
+            // Reconstruct canonical URL for NIP-98 verification.
+            let canonical_url = reconstruct_canonical_url_for_tokens(&headers, &state);
+
+            // The Authorization: Nostr header value is base64-encoded JSON.
+            // Decode it before passing to verify_nip98_event which expects JSON.
+            let decoded_bytes = {
+                use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+                BASE64.decode(encoded).map_err(|_| {
+                    tracing::warn!("post_tokens: NIP-98 base64 decode failed");
+                    (
+                        StatusCode::UNAUTHORIZED,
+                        Json(serde_json::json!({
+                            "error": "invalid_auth",
+                            "message": "NIP-98 verification failed"
+                        })),
+                    )
+                })?
+            };
+            let event_json = String::from_utf8(decoded_bytes).map_err(|_| {
+                tracing::warn!("post_tokens: NIP-98 decoded bytes are not valid UTF-8");
+                (
+                    StatusCode::UNAUTHORIZED,
+                    Json(serde_json::json!({
+                        "error": "invalid_auth",
+                        "message": "NIP-98 verification failed"
+                    })),
+                )
+            })?;
+
+            match sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", Some(&body))
+            {
+                Ok(pubkey) => {
+                    // POST /api/tokens requires the payload tag — body must be
+                    // cryptographically bound to the signed event.
+                    let event: nostr::Event =
+                        nostr::Event::from_json(&event_json).expect("already verified");
+                    let has_payload = event.tags.find(nostr::TagKind::Payload).is_some();
+                    if !has_payload {
+                        tracing::warn!("post_tokens: NIP-98 event missing required payload tag");
+                        return Err((
+                            StatusCode::UNAUTHORIZED,
+                            Json(serde_json::json!({
+                                "error": "invalid_auth",
+                                "message": "NIP-98 payload tag required for POST /api/tokens"
+                            })),
+                        ));
+                    }
+                    let pubkey_bytes = pubkey.to_bytes().to_vec();
+                    if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
+                        tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}");
+                    }
+                    super::RestAuthContext {
+                        pubkey,
+                        pubkey_bytes,
+                        scopes: vec![],
+                        auth_method: RestAuthMethod::Nip98,
+                        token_id: None,
+                        channel_ids: None,
+                    }
+                }
+                Err(e) => {
+                    tracing::warn!("post_tokens: NIP-98 verification failed: {e}");
+                    return Err((
+                        StatusCode::UNAUTHORIZED,
+                        Json(serde_json::json!({
+                            "error": "invalid_auth",
+                            "message": "NIP-98 verification failed"
+                        })),
+                    ));
+                }
+            }
+        } else {
+            extract_auth_context(&headers, &state).await?
+        }
+    } else {
+        extract_auth_context(&headers, &state).await?
+    };
+
+    // ── Validate name ─────────────────────────────────────────────────────────
+    if req.name.is_empty() || req.name.len() > 100 {
+        return Err(api_error(
+            StatusCode::BAD_REQUEST,
+            "invalid_name: name must be 1–100 characters",
+        ));
+    }
+
+    // ── Validate scopes ───────────────────────────────────────────────────────
+    if req.scopes.is_empty() {
+        return Err(api_error(
+            StatusCode::BAD_REQUEST,
+            "invalid_scopes: scopes must not be empty",
+        ));
+    }
+
+    let mut parsed_scopes: Vec<Scope> = Vec::with_capacity(req.scopes.len());
+    for s in &req.scopes {
+        let scope: Scope = s.parse().expect("infallible");
+        match &scope {
+            Scope::Unknown(_) => {
+                return Err((
+                    StatusCode::BAD_REQUEST,
+                    Json(serde_json::json!({
+                        "error": "invalid_scopes",
+                        "message": format!("unknown scope: {s}")
+                    })),
+                ));
+            }
+            _ => {
+                if !is_self_mintable(&scope) {
+                    return Err((
+                        StatusCode::BAD_REQUEST,
+                        Json(serde_json::json!({
+                            "error": "invalid_scopes",
+                            "message": format!("scope requires admin: {s}")
+                        })),
+                    ));
+                }
+            }
+        }
+        parsed_scopes.push(scope);
+    }
+
+    // Deduplicate scopes (order-preserving).
+    let mut seen = std::collections::HashSet::new();
+    parsed_scopes.retain(|s| seen.insert(s.clone()));
+
+    // ── Scope escalation prevention (Bearer-authenticated callers) ───────────
+    // If the caller authenticated via an API token, the requested scopes must be
+    // a subset of the caller's own scopes, and channel_ids must be a subset too.
+    // NIP-98 and Okta JWT callers are unrestricted (they authenticate the identity
+    // directly, not via a scoped token).
+    if matches!(ctx.auth_method, RestAuthMethod::ApiToken) {
+        for scope in &parsed_scopes {
+            if !ctx.scopes.contains(scope) {
+                return Err((
+                    StatusCode::FORBIDDEN,
+                    Json(serde_json::json!({
+                        "error": "scope_escalation",
+                        "message": format!("Cannot mint scope '{}' — not in your token's scopes", scope)
+                    })),
+                ));
+            }
+        }
+
+        // If caller has channel_ids restriction, child must also be restricted
+        // to a subset of those channels.
+        if let Some(ref caller_channels) = ctx.channel_ids {
+            match &req.channel_ids {
+                None => {
+                    return Err((
+                        StatusCode::FORBIDDEN,
+                        Json(serde_json::json!({
+                            "error": "channel_escalation",
+                            "message": "Your token is channel-restricted; minted tokens must also specify channel_ids (subset of yours)"
+                        })),
+                    ));
+                }
+                Some(requested_raw) => {
+                    // Parse requested channel IDs (already validated above, but we need UUIDs here).
+                    for raw in requested_raw {
+                        if let Ok(cid) = raw.parse::<uuid::Uuid>() {
+                            if !caller_channels.contains(&cid) {
+                                return Err((
+                                    StatusCode::FORBIDDEN,
+                                    Json(serde_json::json!({
+                                        "error": "channel_escalation",
+                                        "message": format!("Cannot mint access to channel {} — not in your token's channel_ids", cid)
+                                    })),
+                                ));
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    // ── Rate limit ────────────────────────────────────────────────────────────
+    let pubkey_bytes_arr: [u8; 32] = ctx
+        .pubkey_bytes
+        .as_slice()
+        .try_into()
+        .map_err(|_| internal_error("pubkey bytes length mismatch"))?;
+
+    if let Err(retry_after) = state.mint_rate_limiter.check_and_record(&pubkey_bytes_arr) {
+        let retry_secs = retry_after.as_secs();
+        return Err((
+            StatusCode::TOO_MANY_REQUESTS,
+            Json(serde_json::json!({
+                "error": "rate_limited",
+                "message": format!(
+                    "Mint limit exceeded: {} per hour. Try again in {} seconds.",
+                    state.mint_rate_limiter.limit(), retry_secs
+                ),
+                "retry_after_seconds": retry_secs
+            })),
+        ));
+    }
+
+    // ── Validate and verify channel_ids ──────────────────────────────────────
+    let validated_channel_ids: Option<Vec<Uuid>> = if let Some(ref raw_ids) = req.channel_ids {
+        if raw_ids.is_empty() {
+            // Empty array is treated as "no restriction" — but if the caller's
+            // token is channel-restricted, this would be an escalation. The subset
+            // check above already rejects `None` for restricted callers, so we
+            // must also reject empty arrays here.
+            if matches!(ctx.auth_method, RestAuthMethod::ApiToken) && ctx.channel_ids.is_some() {
+                return Err((
+                    StatusCode::FORBIDDEN,
+                    Json(serde_json::json!({
+                        "error": "channel_escalation",
+                        "message": "Your token is channel-restricted; minted tokens must specify non-empty channel_ids (subset of yours)"
+                    })),
+                ));
+            }
+            None
+        } else {
+            let mut uuids = Vec::with_capacity(raw_ids.len());
+            for raw in raw_ids {
+                let cid: Uuid = raw.parse().map_err(|_| {
+                    (
+                        StatusCode::BAD_REQUEST,
+                        Json(serde_json::json!({
+                            "error": "invalid_channel_ids",
+                            "message": format!("malformed UUID: {raw}")
+                        })),
+                    )
+                })?;
+
+                // Verify channel exists.
+                let channel = state.db.get_channel(cid).await.map_err(|_| {
+                    (
+                        StatusCode::BAD_REQUEST,
+                        Json(serde_json::json!({
+                            "error": "invalid_channel_ids",
+                            "message": format!("channel not found: {cid}")
+                        })),
+                    )
+                })?;
+                let _ = channel; // existence confirmed
+
+                // Verify caller is a member of the channel.
+                let is_member = state
+                    .db
+                    .is_member(cid, &ctx.pubkey_bytes)
+                    .await
+                    .map_err(|e| internal_error(&format!("db error: {e}")))?;
+                if !is_member {
+                    return Err((
+                        StatusCode::FORBIDDEN,
+                        Json(serde_json::json!({
+                            "error": "not_channel_member",
+                            "message": format!("not a member of channel: {cid}")
+                        })),
+                    ));
+                }
+
+                uuids.push(cid);
+            }
+            // Deduplicate channel_ids (sorted; UUIDs have no meaningful order).
+            uuids.sort();
+            uuids.dedup();
+            Some(uuids)
+        }
+    } else {
+        None
+    };
+
+    // ── Validate expires_in_days ──────────────────────────────────────────────
+    if let Some(days) = req.expires_in_days {
+        if days == 0 || days > 365 {
+            return Err(api_error(
+                StatusCode::BAD_REQUEST,
+                "invalid expires_in_days: must be 1–365",
+            ));
+        }
+    }
+
+    let expires_at = req
+        .expires_in_days
+        .map(|days| Utc::now() + chrono::Duration::days(days as i64));
+
+    // ── Generate token ────────────────────────────────────────────────────────
+    let raw_token = sprout_auth::generate_token();
+    let token_hash: Vec<u8> = Sha256::digest(raw_token.as_bytes()).to_vec();
+    let scope_strings: Vec<String> = parsed_scopes.iter().map(|s| s.to_string()).collect();
+
+    // ── Conditional INSERT (enforces 10-token limit atomically) ──────────────
+    let channel_ids_slice = validated_channel_ids.as_deref();
+    let token_id = state
+        .db
+        .create_api_token_if_under_limit(
+            &token_hash,
+            &ctx.pubkey_bytes,
+            &req.name,
+            &scope_strings,
+            channel_ids_slice,
+            expires_at,
+        )
+        .await
+        .map_err(|e| internal_error(&format!("db error creating token: {e}")))?;
+
+    let token_id = match token_id {
+        Some(id) => id,
+        None => {
+            return Err((
+                StatusCode::CONFLICT,
+                Json(serde_json::json!({
+                    "error": "token_limit_exceeded",
+                    "message": "Maximum of 10 active tokens per pubkey"
+                })),
+            ));
+        }
+    };
+
+    // ── Set agent owner ─────────────────────────────────────────────────────
+    // Self-minted tokens always set the caller as the agent owner. This is the
+    // same field that `sprout-admin mint-token --owner-pubkey` sets, ensuring
+    // self-minted agents have the same ownership semantics as admin-minted ones.
+    if let Err(e) = state
+        .db
+        .set_agent_owner(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
+        .await
+    {
+        tracing::warn!("set_agent_owner failed for self-mint: {e}");
+        // Non-fatal — token was already created. Owner field is a convenience,
+        // not a security gate. Log and continue.
+    }
+
+    // ── Build response ────────────────────────────────────────────────────────
+    let channel_ids_response: Vec<String> = validated_channel_ids
+        .as_deref()
+        .unwrap_or(&[])
+        .iter()
+        .map(|id| id.to_string())
+        .collect();
+
+    let resp = MintTokenResponse {
+        id: token_id,
+        token: raw_token,
+        name: req.name,
+        scopes: scope_strings,
+        channel_ids: channel_ids_response,
+        created_at: Utc::now().to_rfc3339(),
+        expires_at: expires_at.map(|t| t.to_rfc3339()),
+    };
+
+    Ok((
+        StatusCode::CREATED,
+        Json(serde_json::to_value(resp).unwrap()),
+    ))
+}
+
+/// `GET /api/tokens` — list all tokens owned by the authenticated pubkey.
+///
+/// Returns all tokens including revoked ones (for audit). Token values are
+/// **never** returned. Requires Bearer token or Okta JWT (not NIP-98 bootstrap).
+pub async fn get_tokens(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
+    let ctx = extract_auth_context(&headers, &state).await?;
+
+    // NIP-98 bootstrap is not allowed for listing — caller must have a real token.
+    if ctx.auth_method == RestAuthMethod::Nip98 {
+        return Err(api_error(
+            StatusCode::UNAUTHORIZED,
+            "Bearer token required to list tokens",
+        ));
+    }
+
+    let records = state
+        .db
+        .list_tokens_by_owner(&ctx.pubkey_bytes)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    let tokens: Vec<TokenListItem> = records
+        .into_iter()
+        .map(|r| {
+            let channel_ids: Vec<String> = r
+                .channel_ids
+                .as_deref()
+                .unwrap_or(&[])
+                .iter()
+                .map(|id| id.to_string())
+                .collect();
+            TokenListItem {
+                id: r.id,
+                name: r.name,
+                scopes: r.scopes,
+                channel_ids,
+                created_at: r.created_at.to_rfc3339(),
+                expires_at: r.expires_at.map(|t| t.to_rfc3339()),
+                last_used_at: r.last_used_at.map(|t| t.to_rfc3339()),
+                revoked_at: r.revoked_at.map(|t| t.to_rfc3339()),
+            }
+        })
+        .collect();
+
+    Ok(Json(serde_json::json!({ "tokens": tokens })))
+}
+
+/// `DELETE /api/tokens/{id}` — revoke a single token by UUID.
+///
+/// The caller must own the token. Returns 204 on success, 404 if not found
+/// or not owned by the caller, 409 if already revoked.
+pub async fn delete_token(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
+    let ctx = extract_auth_context(&headers, &state).await?;
+
+    if ctx.auth_method == RestAuthMethod::Nip98 {
+        return Err(api_error(
+            StatusCode::UNAUTHORIZED,
+            "Bearer token required to revoke tokens",
+        ));
+    }
+
+    // First, check if the token exists and is owned by the caller — to distinguish
+    // "not found / not owned" (404) from "already revoked" (409).
+    // We use get_api_token_by_hash_including_revoked is not applicable here (we have
+    // the UUID, not the hash). Instead, we attempt the revoke and then check existence.
+    let revoked = state
+        .db
+        .revoke_token(id, &ctx.pubkey_bytes, &ctx.pubkey_bytes)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    if revoked {
+        return Ok(StatusCode::NO_CONTENT);
+    }
+
+    // rows_affected == 0: either not found, not owned, or already revoked.
+    // Check if the token exists at all (including revoked) to distinguish the cases.
+    let all_tokens = state
+        .db
+        .list_tokens_by_owner(&ctx.pubkey_bytes)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    let found = all_tokens.iter().find(|t| t.id == id);
+    match found {
+        Some(t) if t.revoked_at.is_some() => Err((
+            StatusCode::CONFLICT,
+            Json(serde_json::json!({
+                "error": "already_revoked",
+                "message": "Token is already revoked"
+            })),
+        )),
+        _ => Err((
+            StatusCode::NOT_FOUND,
+            Json(serde_json::json!({
+                "error": "not_found",
+                "message": "Token not found or not owned by caller"
+            })),
+        )),
+    }
+}
+
+/// `DELETE /api/tokens` — revoke all tokens (panic button).
+///
+/// Revokes all active tokens for the authenticated pubkey, including the token
+/// used to make this call. Skips already-revoked tokens (idempotent).
+/// Returns `{ "revoked_count": N }` with 200.
+pub async fn delete_all_tokens(
+    State(state): State<Arc<AppState>>,
+    headers: HeaderMap,
+) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
+    let ctx = extract_auth_context(&headers, &state).await?;
+
+    if ctx.auth_method == RestAuthMethod::Nip98 {
+        return Err(api_error(
+            StatusCode::UNAUTHORIZED,
+            "Bearer token required to revoke tokens",
+        ));
+    }
+
+    let revoked_count = state
+        .db
+        .revoke_all_tokens(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
+        .await
+        .map_err(|e| internal_error(&format!("db error: {e}")))?;
+
+    Ok(Json(serde_json::json!({ "revoked_count": revoked_count })))
+}
+
+// ── Internal helpers ──────────────────────────────────────────────────────────
+
+/// Reconstruct the canonical URL for NIP-98 verification on the token mint endpoint.
+fn reconstruct_canonical_url_for_tokens(headers: &HeaderMap, state: &AppState) -> String {
+    let proto = headers
+        .get("x-forwarded-proto")
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("https");
+    let host = headers
+        .get("x-forwarded-host")
+        .or_else(|| headers.get("host"))
+        .and_then(|v| v.to_str().ok());
+
+    if let Some(host) = host {
+        format!("{proto}://{host}/api/tokens")
+    } else {
+        let base = state
+            .config
+            .relay_url
+            .replace("wss://", "https://")
+            .replace("ws://", "http://");
+        format!("{base}/api/tokens")
+    }
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rate_limiter_allows_up_to_limit() {
+        let limiter = MintRateLimiter::new();
+        let key = [0u8; 32];
+        for _ in 0..DEFAULT_MINT_LIMIT {
+            assert!(limiter.check_and_record(&key).is_ok());
+        }
+        // 6th call should be denied.
+        assert!(limiter.check_and_record(&key).is_err());
+    }
+
+    #[test]
+    fn rate_limiter_different_pubkeys_are_independent() {
+        let limiter = MintRateLimiter::new();
+        let key_a = [1u8; 32];
+        let key_b = [2u8; 32];
+        for _ in 0..DEFAULT_MINT_LIMIT {
+            assert!(limiter.check_and_record(&key_a).is_ok());
+        }
+        // key_b should still be allowed.
+        assert!(limiter.check_and_record(&key_b).is_ok());
+    }
+
+    #[test]
+    fn rate_limiter_returns_retry_after_duration() {
+        let limiter = MintRateLimiter::new();
+        let key = [3u8; 32];
+        for _ in 0..DEFAULT_MINT_LIMIT {
+            let _ = limiter.check_and_record(&key);
+        }
+        let err = limiter.check_and_record(&key).unwrap_err();
+        // retry_after should be ≤ MINT_WINDOW and > 0.
+        assert!(err > Duration::ZERO);
+        assert!(err <= MINT_WINDOW);
+    }
+
+    #[test]
+    fn mint_token_request_deserializes() {
+        let json = r#"{
+            "name": "my-agent",
+            "scopes": ["messages:read", "messages:write"],
+            "expires_in_days": 30
+        }"#;
+        let req: MintTokenRequest = serde_json::from_str(json).unwrap();
+        assert_eq!(req.name, "my-agent");
+        assert_eq!(req.scopes.len(), 2);
+        assert_eq!(req.expires_in_days, Some(30));
+        assert!(req.channel_ids.is_none());
+    }
+
+    #[test]
+    fn mint_token_request_with_channel_ids() {
+        let json = r#"{
+            "name": "channel-agent",
+            "scopes": ["messages:read"],
+            "channel_ids": ["01950a3b-c000-7000-8000-000000000001"]
+        }"#;
+        let req: MintTokenRequest = serde_json::from_str(json).unwrap();
+        assert_eq!(req.channel_ids.as_ref().unwrap().len(), 1);
+    }
+}
diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs
index 144540fa03..5f69a09259 100644
--- a/crates/sprout-relay/src/api/users.rs
+++ b/crates/sprout-relay/src/api/users.rs
@@ -18,7 +18,7 @@ use serde::Deserialize;
 
 use crate::state::AppState;
 
-use super::{api_error, extract_auth_pubkey, internal_error};
+use super::{api_error, extract_auth_context, internal_error, scope_error};
 
 /// Request body for updating a user's profile.
 /// All fields are optional — at least one must be present.
@@ -43,7 +43,9 @@ pub async fn update_profile(
     headers: HeaderMap,
     ExtractJson(body): ExtractJson<UpdateProfileBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let display_name = body
         .display_name
@@ -108,7 +110,9 @@ pub async fn get_profile(
     State(state): State<Arc<AppState>>,
     headers: HeaderMap,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let profile = state
         .db
@@ -134,7 +138,8 @@ pub async fn get_user_profile(
     headers: HeaderMap,
     Path(pubkey_hex): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let _ = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
 
     let pubkey_bytes = nostr_hex::decode(&pubkey_hex)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?;
@@ -174,7 +179,8 @@ pub async fn get_users_batch(
     headers: HeaderMap,
     ExtractJson(body): ExtractJson<BatchProfilesRequest>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let _ = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
 
     if body.pubkeys.len() > 200 {
         return Err(api_error(
@@ -262,7 +268,9 @@ pub async fn put_channel_add_policy(
     headers: HeaderMap,
     ExtractJson(body): ExtractJson<UpdateChannelAddPolicyBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey_hex, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let policy = body.channel_add_policy.as_str();
     if !matches!(policy, "anyone" | "owner_only" | "nobody") {
diff --git a/crates/sprout-relay/src/api/workflows.rs b/crates/sprout-relay/src/api/workflows.rs
index f45651c06e..51144a77c5 100644
--- a/crates/sprout-relay/src/api/workflows.rs
+++ b/crates/sprout-relay/src/api/workflows.rs
@@ -26,7 +26,8 @@ use super::workflow_helpers::{
     validate_webhook_urls, workflow_record_to_json,
 };
 use super::{
-    api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
+    api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
+    internal_error, not_found, scope_error,
 };
 
 // ── GET /api/channels/:channel_id/workflows ───────────────────────────────────
@@ -37,11 +38,15 @@ pub async fn list_channel_workflows(
     headers: HeaderMap,
     Path(channel_id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     let workflows = state
@@ -73,11 +78,15 @@ pub async fn create_workflow(
     Path(channel_id_str): Path<String>,
     Json(body): Json<CreateWorkflowBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let channel_id = uuid::Uuid::parse_str(&channel_id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
 
+    check_token_channel_access(&ctx, &channel_id)?;
     check_channel_access(&state, channel_id, &pubkey_bytes).await?;
 
     let (def, definition_json_str) =
@@ -150,7 +159,10 @@ pub async fn get_workflow(
     headers: HeaderMap,
     Path(id_str): Path<String>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id = uuid::Uuid::parse_str(&id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -162,6 +174,7 @@ pub async fn get_workflow(
         .map_err(|_| not_found("workflow not found"))?;
 
     if let Some(channel_id) = workflow.channel_id {
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     } else if workflow.owner_pubkey != pubkey_bytes {
         return Err(forbidden("not authorized to access this workflow"));
@@ -189,7 +202,10 @@ pub async fn update_workflow(
     Path(id_str): Path<String>,
     Json(body): Json<UpdateWorkflowBody>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id = uuid::Uuid::parse_str(&id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -201,6 +217,7 @@ pub async fn update_workflow(
         .map_err(|_| not_found("workflow not found"))?;
 
     if let Some(channel_id) = existing.channel_id {
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     } else if existing.owner_pubkey != pubkey_bytes {
         return Err(forbidden("not authorized to access this workflow"));
@@ -278,7 +295,10 @@ pub async fn delete_workflow(
     headers: HeaderMap,
     Path(id_str): Path<String>,
 ) -> Result<axum::response::Response, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id = uuid::Uuid::parse_str(&id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -291,6 +311,7 @@ pub async fn delete_workflow(
 
     if workflow.owner_pubkey != pubkey_bytes {
         if let Some(channel_id) = workflow.channel_id {
+            check_token_channel_access(&ctx, &channel_id)?;
             check_channel_access(&state, channel_id, &pubkey_bytes)
                 .await
                 .map_err(|_| forbidden("not authorized to delete this workflow"))?;
@@ -325,7 +346,10 @@ pub async fn list_workflow_runs(
     Path(id_str): Path<String>,
     Query(params): Query<RunsParams>,
 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id = uuid::Uuid::parse_str(&id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -337,6 +361,7 @@ pub async fn list_workflow_runs(
         .map_err(|_| not_found("workflow not found"))?;
 
     if let Some(channel_id) = workflow.channel_id {
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     }
 
@@ -359,7 +384,10 @@ pub async fn trigger_workflow(
     headers: HeaderMap,
     Path(id_str): Path<String>,
 ) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
-    let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
+    let ctx = extract_auth_context(&headers, &state).await?;
+    sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
+        .map_err(scope_error)?;
+    let pubkey_bytes = ctx.pubkey_bytes.clone();
 
     let id = uuid::Uuid::parse_str(&id_str)
         .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -371,6 +399,7 @@ pub async fn trigger_workflow(
         .map_err(|_| not_found("workflow not found"))?;
 
     if let Some(channel_id) = workflow.channel_id {
+        check_token_channel_access(&ctx, &channel_id)?;
         check_channel_access(&state, channel_id, &pubkey_bytes).await?;
     }
 
diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs
index 17f9140c87..dc15de495a 100644
--- a/crates/sprout-relay/src/router.rs
+++ b/crates/sprout-relay/src/router.rs
@@ -14,6 +14,7 @@ use tower_http::limit::RequestBodyLimitLayer;
 use tower_http::trace::TraceLayer;
 
 use crate::api;
+use crate::api::tokens;
 use crate::connection::handle_connection;
 use crate::nip11::{relay_info_handler, RelayInfo};
 use crate::state::AppState;
@@ -25,6 +26,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
         .route("/info", get(relay_info_handler))
         .route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
         .route("/health", get(health_handler))
+        // Token self-service routes
+        .route(
+            "/api/tokens",
+            post(tokens::post_tokens)
+                .get(tokens::get_tokens)
+                .delete(tokens::delete_all_tokens),
+        )
+        .route("/api/tokens/{id}", delete(tokens::delete_token))
         .route(
             "/api/channels",
             get(api::channels_handler).post(api::create_channel),
diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs
index 31a35b2fa2..1a1b4d6a4f 100644
--- a/crates/sprout-relay/src/state.rs
+++ b/crates/sprout-relay/src/state.rs
@@ -1,6 +1,7 @@
 //! Shared application state — Arc-wrapped, shared across all connections.
 
 use std::sync::Arc;
+use std::time::Instant;
 
 use axum::extract::ws::Message as WsMessage;
 use dashmap::DashMap;
@@ -14,6 +15,7 @@ use sprout_pubsub::PubSubManager;
 use sprout_search::SearchService;
 use sprout_workflow::WorkflowEngine;
 
+use crate::api::tokens::MintRateLimiter;
 use crate::config::Config;
 use crate::subscription::SubscriptionRegistry;
 
@@ -84,6 +86,12 @@ pub struct AppState {
     pub workflow_engine: Arc<WorkflowEngine>,
     /// Relay signing keypair — used to sign system messages (kind 40099).
     pub relay_keypair: nostr::Keys,
+    /// Rate limiter for `POST /api/tokens` — 5 mints per pubkey per hour.
+    pub mint_rate_limiter: Arc<MintRateLimiter>,
+    /// Debounce cache for `last_used_at` token updates — avoids a DB write on every request.
+    /// Entries map token UUID → last time we wrote `last_used_at` to the DB.
+    /// Resets on restart (acceptable — `last_used_at` is informational, not security-critical).
+    pub last_used_cache: Arc<DashMap<Uuid, Instant>>,
 }
 
 impl AppState {
@@ -114,6 +122,8 @@ impl AppState {
             handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)),
             workflow_engine,
             relay_keypair,
+            mint_rate_limiter: Arc::new(MintRateLimiter::new()),
+            last_used_cache: Arc::new(DashMap::new()),
         }
     }
 }
diff --git a/crates/sprout-test-client/Cargo.toml b/crates/sprout-test-client/Cargo.toml
index 6c1ecc6273..d509ae9752 100644
--- a/crates/sprout-test-client/Cargo.toml
+++ b/crates/sprout-test-client/Cargo.toml
@@ -28,6 +28,10 @@ tracing-subscriber = { workspace = true }
 uuid = { workspace = true }
 futures-util = { workspace = true }
 reqwest = { workspace = true }
+base64 = "0.22"
+sha2 = "0.10"
+chrono = { workspace = true }
+hex = "0.4"
 
 [[bin]]
 name = "sprout-test-cli"
diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs
index 965a2faf73..fb6e641e60 100644
--- a/crates/sprout-test-client/tests/e2e_mcp.rs
+++ b/crates/sprout-test-client/tests/e2e_mcp.rs
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
 
     assert_eq!(
         tools.len(),
-        42,
-        "expected exactly 42 tools, got {}. Tools: {:?}",
+        43,
+        "expected exactly 43 tools, got {}. Tools: {:?}",
         tools.len(),
         tools
             .iter()
diff --git a/crates/sprout-test-client/tests/e2e_tokens.rs b/crates/sprout-test-client/tests/e2e_tokens.rs
new file mode 100644
index 0000000000..f25583ac98
--- /dev/null
+++ b/crates/sprout-test-client/tests/e2e_tokens.rs
@@ -0,0 +1,1018 @@
+//! E2E tests for the self-service token minting API.
+//!
+//! These tests require a running relay instance with `require_auth_token=false`
+//! (dev mode). By default they are marked `#[ignore]` so that `cargo test`
+//! does not fail in CI when the relay is not available.
+//!
+//! # Running
+//!
+//! Start the relay, then run:
+//!
+//! ```text
+//! RELAY_URL=ws://localhost:3000 cargo test -p sprout-test-client --test e2e_tokens -- --ignored
+//! ```
+//!
+//! # Auth
+//!
+//! In dev mode (`require_auth_token=false`) the relay accepts an
+//! `X-Pubkey: <hex>` header as authentication, granting all scopes.
+//! Tests generate fresh [`nostr::Keys`] per test for isolation.
+
+use std::time::Duration;
+
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
+use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
+use reqwest::Client;
+use sha2::{Digest, Sha256};
+
+// ── URL helpers ───────────────────────────────────────────────────────────────
+
+/// WebSocket relay URL (e.g. `ws://localhost:3000`).
+fn relay_ws_url() -> String {
+    std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string())
+}
+
+/// HTTP base URL derived from the WebSocket URL.
+fn relay_http_url() -> String {
+    relay_ws_url()
+        .replace("wss://", "https://")
+        .replace("ws://", "http://")
+}
+
+/// Build a `reqwest::Client` with a short timeout.
+fn http_client() -> Client {
+    Client::builder()
+        .timeout(Duration::from_secs(10))
+        .build()
+        .expect("failed to build HTTP client")
+}
+
+// ── NIP-98 helpers ────────────────────────────────────────────────────────────
+
+/// Build a `Authorization: Nostr <base64>` header value for NIP-98 HTTP Auth.
+///
+/// Uses kind 27235 (`Kind::HttpAuth`) with `u`, `method`, and `payload` tags
+/// following the pattern in `sprout-auth/src/nip98.rs`.
+fn build_nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String {
+    let payload_hash = hex::encode(Sha256::digest(body));
+
+    let tags = vec![
+        Tag::parse(&["u", url]).expect("u tag"),
+        Tag::parse(&["method", method]).expect("method tag"),
+        Tag::parse(&["payload", &payload_hash]).expect("payload tag"),
+    ];
+
+    let event = EventBuilder::new(Kind::HttpAuth, "", tags)
+        .sign_with_keys(keys)
+        .expect("signing must succeed");
+
+    let json = event.as_json();
+    let encoded = BASE64.encode(json.as_bytes());
+    format!("Nostr {encoded}")
+}
+
+/// Build a `Authorization: Nostr <base64>` header value for NIP-98 HTTP Auth
+/// **without** the payload tag.
+///
+/// Used to test that `POST /api/tokens` rejects NIP-98 events that don't
+/// cryptographically bind the request body.
+fn build_nip98_header_no_payload(keys: &Keys, url: &str, method: &str) -> String {
+    let tags = vec![
+        Tag::parse(&["u", url]).expect("u tag"),
+        Tag::parse(&["method", method]).expect("method tag"),
+        // Deliberately omit payload tag
+    ];
+    let event = EventBuilder::new(Kind::HttpAuth, "", tags)
+        .sign_with_keys(keys)
+        .expect("signing must succeed");
+    let json = event.as_json();
+    let encoded = BASE64.encode(json.as_bytes());
+    format!("Nostr {encoded}")
+}
+
+// ── Mint helper ───────────────────────────────────────────────────────────────
+
+/// Mint a token via dev-mode `X-Pubkey` header. Returns the parsed response body.
+async fn mint_token_dev(
+    client: &Client,
+    pubkey_hex: &str,
+    name: &str,
+    scopes: &[&str],
+) -> serde_json::Value {
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": name,
+        "scopes": scopes,
+    });
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+    assert_eq!(
+        resp.status(),
+        201,
+        "expected 201, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+    resp.json().await.expect("response JSON")
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+/// POST /api/tokens via dev-mode X-Pubkey header returns 201 with token fields.
+#[tokio::test]
+#[ignore]
+async fn test_mint_token_via_dev_mode() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "dev-mode-token",
+        "scopes": ["messages:read", "messages:write"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(resp.status(), 201, "expected 201 Created");
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert!(json["id"].is_string(), "id should be a string UUID");
+    assert!(json["token"].is_string(), "token should be a string");
+    assert!(
+        !json["token"].as_str().unwrap().is_empty(),
+        "token should not be empty"
+    );
+    assert_eq!(json["name"], "dev-mode-token");
+    assert!(json["scopes"].is_array());
+    let scopes = json["scopes"].as_array().unwrap();
+    assert!(scopes.iter().any(|s| s == "messages:read"));
+    assert!(scopes.iter().any(|s| s == "messages:write"));
+    assert!(json["created_at"].is_string());
+    // expires_at should be null (no expiry requested)
+    assert!(json["expires_at"].is_null());
+}
+
+/// POST /api/tokens via NIP-98 Authorization header returns 201.
+#[tokio::test]
+#[ignore]
+async fn test_mint_token_via_nip98() {
+    let client = http_client();
+    let keys = Keys::generate();
+
+    let endpoint_url = format!("{}/api/tokens", relay_http_url());
+    let body_json = serde_json::json!({
+        "name": "nip98-token",
+        "scopes": ["messages:read"],
+    });
+    let body_bytes = serde_json::to_vec(&body_json).unwrap();
+
+    // The relay's reconstruct_canonical_url_for_tokens uses X-Forwarded-Proto
+    // to determine the scheme. We pass "http" so the canonical URL matches
+    // what we sign in the NIP-98 event.
+    let auth_header = build_nip98_header(&keys, &endpoint_url, "POST", &body_bytes);
+
+    let resp = client
+        .post(&endpoint_url)
+        .header("Authorization", auth_header)
+        .header("X-Forwarded-Proto", "http")
+        .header("Content-Type", "application/json")
+        .body(body_bytes)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        201,
+        "expected 201, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert!(json["id"].is_string());
+    assert!(json["token"].is_string());
+    assert_eq!(json["name"], "nip98-token");
+}
+
+/// POST /api/tokens with admin scope returns 400 "scope requires admin".
+#[tokio::test]
+#[ignore]
+async fn test_mint_rejects_admin_scopes() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "admin-attempt",
+        "scopes": ["admin:channels"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(resp.status(), 400, "expected 400, got {}", resp.status());
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let msg = json["message"].as_str().unwrap_or("");
+    assert!(
+        msg.contains("scope requires admin"),
+        "expected 'scope requires admin' in message, got: {msg}"
+    );
+}
+
+/// POST /api/tokens with empty scopes array returns 400.
+#[tokio::test]
+#[ignore]
+async fn test_mint_rejects_empty_scopes() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "empty-scopes",
+        "scopes": [],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(resp.status(), 400, "expected 400 for empty scopes");
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let err_msg = json["error"].as_str().unwrap_or("");
+    assert!(
+        err_msg.contains("invalid_scopes"),
+        "expected 'invalid_scopes' in error, got: {err_msg}"
+    );
+}
+
+/// POST /api/tokens with an unknown scope returns 400 "unknown scope".
+#[tokio::test]
+#[ignore]
+async fn test_mint_rejects_unknown_scopes() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "unknown-scope",
+        "scopes": ["future:capability"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(resp.status(), 400, "expected 400 for unknown scope");
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let msg = json["message"].as_str().unwrap_or("");
+    assert!(
+        msg.contains("unknown scope"),
+        "expected 'unknown scope' in message, got: {msg}"
+    );
+}
+
+/// POST /api/tokens with a name longer than 100 characters returns 400.
+#[tokio::test]
+#[ignore]
+async fn test_mint_rejects_long_name() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    // 101 'a' characters — one over the 100-char limit.
+    let long_name = "a".repeat(101);
+    let body = serde_json::json!({
+        "name": long_name,
+        "scopes": ["messages:read"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        400,
+        "expected 400 for name > 100 chars, got {}",
+        resp.status()
+    );
+}
+
+/// GET /api/tokens with X-Pubkey (dev mode) works; lists minted tokens.
+#[tokio::test]
+#[ignore]
+async fn test_list_tokens_via_dev_mode() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint a token first so the list is non-empty.
+    let minted = mint_token_dev(&client, &pubkey_hex, "list-test-token", &["messages:read"]).await;
+    let minted_id = minted["id"].as_str().expect("id");
+
+    // List tokens via dev-mode X-Pubkey (dev mode grants all scopes).
+    let list_url = format!("{}/api/tokens", relay_http_url());
+    let resp = client
+        .get(&list_url)
+        .header("X-Pubkey", &pubkey_hex)
+        .send()
+        .await
+        .expect("GET /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        200,
+        "expected 200, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let tokens = json["tokens"].as_array().expect("tokens array");
+    assert!(
+        tokens.iter().any(|t| t["id"] == minted_id),
+        "minted token {minted_id} not found in list"
+    );
+}
+
+/// GET /api/tokens with NIP-98 Authorization returns 401 — listing requires Bearer.
+///
+/// Note: the auth layer (`extract_auth_context`) hardcodes `"POST"` when verifying
+/// NIP-98 events, so a GET request with a NIP-98 header fails at the NIP-98
+/// verification step ("NIP-98 verification failed") before reaching the handler's
+/// "Bearer token required" check. Either way the result is 401 — NIP-98 cannot
+/// be used to list tokens.
+#[tokio::test]
+#[ignore]
+async fn test_nip98_rejected_for_list_tokens() {
+    let client = http_client();
+    let keys = Keys::generate();
+
+    let endpoint_url = format!("{}/api/tokens", relay_http_url());
+    // GET has no body, so we sign with an empty body.
+    let auth_header = build_nip98_header(&keys, &endpoint_url, "GET", b"");
+
+    let resp = client
+        .get(&endpoint_url)
+        .header("Authorization", auth_header)
+        .header("X-Forwarded-Proto", "http")
+        .send()
+        .await
+        .expect("GET /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        401,
+        "expected 401 for NIP-98 on GET /api/tokens, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    // The auth layer explicitly rejects NIP-98 for non-token endpoints.
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert_eq!(
+        json["error"], "nip98_not_supported",
+        "expected nip98_not_supported error, got: {}",
+        json
+    );
+}
+
+/// POST /api/tokens with NIP-98 auth but WITHOUT the payload tag returns 401.
+///
+/// The payload tag cryptographically binds the request body to the signed event.
+/// Without it, the server cannot verify the body hasn't been substituted.
+///
+/// NOTE: This test depends on the server-side payload-required check added in
+/// the tokens handler. If it returns 201 instead of 401, the handler update
+/// has not yet been deployed — the test will pass once it is.
+#[tokio::test]
+#[ignore]
+async fn test_nip98_requires_payload_tag() {
+    let client = http_client();
+    let keys = Keys::generate();
+
+    let endpoint_url = format!("{}/api/tokens", relay_http_url());
+    let body_json = serde_json::json!({
+        "name": "no-payload-tag-token",
+        "scopes": ["messages:read"],
+    });
+    let body_bytes = serde_json::to_vec(&body_json).unwrap();
+
+    // Sign WITHOUT the payload tag — body is not cryptographically bound.
+    let auth_header = build_nip98_header_no_payload(&keys, &endpoint_url, "POST");
+
+    let resp = client
+        .post(&endpoint_url)
+        .header("Authorization", auth_header)
+        .header("X-Forwarded-Proto", "http")
+        .header("Content-Type", "application/json")
+        .body(body_bytes)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        401,
+        "expected 401 for NIP-98 without payload tag, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+}
+
+/// POST /api/tokens with NIP-98 auth where the payload tag has a wrong hash returns 401.
+///
+/// The payload tag must be SHA-256(request body). Sending a hash of "wrong body"
+/// while sending the real body should be rejected.
+#[tokio::test]
+#[ignore]
+async fn test_nip98_wrong_payload_rejected() {
+    let client = http_client();
+    let keys = Keys::generate();
+
+    let endpoint_url = format!("{}/api/tokens", relay_http_url());
+    let body_json = serde_json::json!({
+        "name": "wrong-payload-token",
+        "scopes": ["messages:read"],
+    });
+    let body_bytes = serde_json::to_vec(&body_json).unwrap();
+
+    // Sign with a hash of "wrong body" — mismatches the actual body sent.
+    let auth_header = build_nip98_header(&keys, &endpoint_url, "POST", b"wrong body");
+
+    let resp = client
+        .post(&endpoint_url)
+        .header("Authorization", auth_header)
+        .header("X-Forwarded-Proto", "http")
+        .header("Content-Type", "application/json")
+        .body(body_bytes)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        401,
+        "expected 401 for NIP-98 with wrong payload hash, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+}
+
+/// Mint a token, revoke it via DELETE /api/tokens/{id}, then verify it's revoked.
+#[tokio::test]
+#[ignore]
+async fn test_revoke_token() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint a token.
+    let minted = mint_token_dev(&client, &pubkey_hex, "revoke-test", &["messages:read"]).await;
+    let token_id = minted["id"].as_str().expect("id");
+    let raw_token = minted["token"].as_str().expect("token");
+
+    // Revoke it using the raw token as Bearer.
+    let delete_url = format!("{}/api/tokens/{}", relay_http_url(), token_id);
+    let resp = client
+        .delete(&delete_url)
+        .header("Authorization", format!("Bearer {raw_token}"))
+        .send()
+        .await
+        .expect("DELETE /api/tokens/{id} failed");
+
+    assert_eq!(
+        resp.status(),
+        204,
+        "expected 204 No Content, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    // Verify the token is now rejected (401) when used.
+    let check_url = format!("{}/api/tokens", relay_http_url());
+    let resp2 = client
+        .get(&check_url)
+        .header("Authorization", format!("Bearer {raw_token}"))
+        .send()
+        .await
+        .expect("GET /api/tokens failed");
+
+    assert_eq!(
+        resp2.status(),
+        401,
+        "revoked token should return 401, got {}",
+        resp2.status()
+    );
+}
+
+/// DELETE /api/tokens/{id} with a valid Bearer token but a non-existent UUID returns 404.
+#[tokio::test]
+#[ignore]
+async fn test_revoke_nonexistent_token_returns_404() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint a real token to use as Bearer auth.
+    let minted = mint_token_dev(&client, &pubkey_hex, "auth-token", &["messages:read"]).await;
+    let raw_token = minted["token"].as_str().expect("token");
+
+    // Try to delete a random UUID that doesn't exist.
+    let random_uuid = uuid::Uuid::new_v4();
+    let delete_url = format!("{}/api/tokens/{}", relay_http_url(), random_uuid);
+    let resp = client
+        .delete(&delete_url)
+        .header("Authorization", format!("Bearer {raw_token}"))
+        .send()
+        .await
+        .expect("DELETE /api/tokens/{id} failed");
+
+    assert_eq!(
+        resp.status(),
+        404,
+        "expected 404 for non-existent token, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert_eq!(
+        json["error"], "not_found",
+        "expected not_found error, got: {}",
+        json
+    );
+}
+
+/// Mint 2 tokens, DELETE /api/tokens (revoke all), verify revoked_count=2.
+#[tokio::test]
+#[ignore]
+async fn test_revoke_all_tokens() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint two tokens.
+    let t1 = mint_token_dev(&client, &pubkey_hex, "revoke-all-1", &["messages:read"]).await;
+    let t2 = mint_token_dev(&client, &pubkey_hex, "revoke-all-2", &["messages:write"]).await;
+    let raw_token_1 = t1["token"].as_str().expect("token 1");
+    let _raw_token_2 = t2["token"].as_str().expect("token 2");
+
+    // Revoke all using the first token as Bearer.
+    let delete_url = format!("{}/api/tokens", relay_http_url());
+    let resp = client
+        .delete(&delete_url)
+        .header("Authorization", format!("Bearer {raw_token_1}"))
+        .send()
+        .await
+        .expect("DELETE /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        200,
+        "expected 200, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let revoked_count = json["revoked_count"].as_u64().expect("revoked_count");
+    assert_eq!(
+        revoked_count, 2,
+        "expected 2 tokens revoked, got {revoked_count}"
+    );
+}
+
+/// Mint a token with only messages:read, then try to use it to mint channels:write — expect 403.
+#[tokio::test]
+#[ignore]
+async fn test_bearer_token_scope_escalation_blocked() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint a limited-scope token.
+    let limited = mint_token_dev(&client, &pubkey_hex, "limited-token", &["messages:read"]).await;
+    let limited_raw = limited["token"].as_str().expect("token");
+
+    // Try to mint a new token with channels:write using the limited token.
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "escalated-token",
+        "scopes": ["channels:write"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("Authorization", format!("Bearer {limited_raw}"))
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        403,
+        "expected 403 Forbidden for scope escalation, got {}",
+        resp.status()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert_eq!(
+        json["error"], "scope_escalation",
+        "expected scope_escalation error, got: {}",
+        json
+    );
+}
+
+/// Exhaust the per-pubkey rate limit, then verify the next mint returns 429.
+///
+/// The relay reads `SPROUT_MINT_RATE_LIMIT` (default 50). This test reads the
+/// same env var so it stays in sync. For fast test runs, start the relay with
+/// `SPROUT_MINT_RATE_LIMIT=5`.
+///
+/// **Requires `SPROUT_MINT_RATE_LIMIT ≤ 10`** — the DB enforces a hard 10-token
+/// cap that fires before the rate limiter when the limit is higher. The test
+/// skips gracefully if the limit is too high.
+#[tokio::test]
+#[ignore]
+async fn test_rate_limit() {
+    let limit: usize = std::env::var("SPROUT_MINT_RATE_LIMIT")
+        .ok()
+        .and_then(|v| v.parse().ok())
+        .unwrap_or(50);
+
+    if limit > 10 {
+        eprintln!(
+            "SKIP test_rate_limit: SPROUT_MINT_RATE_LIMIT={limit} exceeds the 10-token DB cap. \
+             Set SPROUT_MINT_RATE_LIMIT=5 (or ≤10) on both relay and test runner to exercise this."
+        );
+        return;
+    }
+
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+    let url = format!("{}/api/tokens", relay_http_url());
+
+    // Mint up to the limit — all should succeed.
+    for i in 0..limit {
+        let body = serde_json::json!({
+            "name": format!("rate-limit-token-{i}"),
+            "scopes": ["messages:read"],
+        });
+        let resp = client
+            .post(&url)
+            .header("X-Pubkey", &pubkey_hex)
+            .json(&body)
+            .send()
+            .await
+            .expect("POST /api/tokens failed");
+        assert_eq!(
+            resp.status(),
+            201,
+            "mint {i}/{limit} should succeed, got {}: {}",
+            resp.status(),
+            resp.text().await.unwrap_or_default()
+        );
+    }
+
+    // Next mint should be rate-limited.
+    let body = serde_json::json!({
+        "name": "rate-limit-over",
+        "scopes": ["messages:read"],
+    });
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        429,
+        "mint {limit}+1 should be rate-limited (429), got {}",
+        resp.status()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert_eq!(json["error"], "rate_limited");
+    assert!(
+        json["retry_after_seconds"].as_u64().unwrap_or(0) > 0,
+        "retry_after_seconds should be positive"
+    );
+}
+
+/// Mint with expires_in_days: 30 — response should have non-null expires_at.
+#[tokio::test]
+#[ignore]
+async fn test_mint_with_expiry() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "expiring-token",
+        "scopes": ["messages:read"],
+        "expires_in_days": 30,
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(resp.status(), 201, "expected 201 Created");
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert!(
+        json["expires_at"].is_string(),
+        "expires_at should be a non-null ISO 8601 string, got: {}",
+        json["expires_at"]
+    );
+
+    // Verify the expiry is approximately 30 days from now.
+    let expires_str = json["expires_at"].as_str().unwrap();
+    let expires = chrono::DateTime::parse_from_rfc3339(expires_str)
+        .expect("expires_at should be valid RFC 3339");
+    let now = chrono::Utc::now();
+    let diff = expires.signed_duration_since(now);
+    let days = diff.num_days();
+    assert!(
+        (29..=31).contains(&days),
+        "expires_at should be ~30 days from now, got {days} days"
+    );
+}
+
+/// POST with expires_in_days: 0 or 400 should return 400.
+#[tokio::test]
+#[ignore]
+async fn test_mint_rejects_invalid_expiry() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+    let url = format!("{}/api/tokens", relay_http_url());
+
+    // expires_in_days: 0 should be rejected.
+    let body_zero = serde_json::json!({
+        "name": "bad-expiry-zero",
+        "scopes": ["messages:read"],
+        "expires_in_days": 0,
+    });
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body_zero)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        400,
+        "expires_in_days: 0 should return 400, got {}",
+        resp.status()
+    );
+
+    // expires_in_days: 400 should be rejected (max is 365).
+    let body_400 = serde_json::json!({
+        "name": "bad-expiry-400",
+        "scopes": ["messages:read"],
+        "expires_in_days": 400,
+    });
+    let resp2 = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body_400)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp2.status(),
+        400,
+        "expires_in_days: 400 should return 400, got {}",
+        resp2.status()
+    );
+}
+
+/// Mint a token, revoke it, then try to revoke it again — expect 409 Conflict.
+///
+/// The second DELETE must return `{ "error": "already_revoked" }` per the
+/// handler's explicit check in `delete_token`.
+#[tokio::test]
+#[ignore]
+async fn test_revoke_already_revoked_returns_409() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    // Mint a token.
+    let minted = mint_token_dev(
+        &client,
+        &pubkey_hex,
+        "double-revoke-test",
+        &["messages:read"],
+    )
+    .await;
+    let token_id = minted["id"].as_str().expect("id");
+    let raw_token = minted["token"].as_str().expect("token");
+
+    let delete_url = format!("{}/api/tokens/{}", relay_http_url(), token_id);
+
+    // First revoke — should succeed with 204.
+    let resp1 = client
+        .delete(&delete_url)
+        .header("Authorization", format!("Bearer {raw_token}"))
+        .send()
+        .await
+        .expect("first DELETE /api/tokens/{id} failed");
+
+    assert_eq!(
+        resp1.status(),
+        204,
+        "first revoke should return 204, got {}: {}",
+        resp1.status(),
+        resp1.text().await.unwrap_or_default()
+    );
+
+    // Second revoke — token is already revoked; must return 409.
+    // We need a different Bearer token to authenticate (the revoked one won't work).
+    // Mint a second token with dev-mode to use as auth for the second DELETE.
+    let auth_token = mint_token_dev(
+        &client,
+        &pubkey_hex,
+        "double-revoke-auth",
+        &["messages:read"],
+    )
+    .await;
+    let auth_raw = auth_token["token"].as_str().expect("auth token");
+
+    let resp2 = client
+        .delete(&delete_url)
+        .header("Authorization", format!("Bearer {auth_raw}"))
+        .send()
+        .await
+        .expect("second DELETE /api/tokens/{id} failed");
+
+    assert_eq!(
+        resp2.status(),
+        409,
+        "second revoke should return 409 Conflict, got {}: {}",
+        resp2.status(),
+        resp2.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp2.json().await.expect("response JSON");
+    assert_eq!(
+        json["error"], "already_revoked",
+        "expected already_revoked error, got: {}",
+        json
+    );
+}
+
+/// NIP-98 auth header sent to a non-token endpoint (GET /api/channels) returns 401.
+///
+/// `extract_auth_context` explicitly rejects NIP-98 on all non-token endpoints
+/// with `{ "error": "nip98_not_supported" }`.
+#[tokio::test]
+#[ignore]
+async fn test_nip98_rejected_for_non_mint_endpoints() {
+    let client = http_client();
+    let keys = Keys::generate();
+
+    let channels_url = format!("{}/api/channels", relay_http_url());
+    // GET has no body — use the no-payload variant.
+    let auth_header = build_nip98_header_no_payload(&keys, &channels_url, "GET");
+
+    let resp = client
+        .get(&channels_url)
+        .header("Authorization", auth_header)
+        .header("X-Forwarded-Proto", "http")
+        .send()
+        .await
+        .expect("GET /api/channels failed");
+
+    assert_eq!(
+        resp.status(),
+        401,
+        "NIP-98 on GET /api/channels should return 401, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    assert_eq!(
+        json["error"], "nip98_not_supported",
+        "expected nip98_not_supported error, got: {}",
+        json
+    );
+}
+
+/// Mint a token with duplicate scopes — response must deduplicate to unique entries.
+///
+/// Sending `["messages:read", "messages:read", "channels:read"]` should result
+/// in exactly 2 unique scopes in the response, not 3.
+///
+/// Deduplication is implemented in `post_tokens` via order-preserving HashSet retain.
+#[tokio::test]
+#[ignore]
+async fn test_mint_with_duplicate_scopes_deduplicates() {
+    let client = http_client();
+    let keys = Keys::generate();
+    let pubkey_hex = keys.public_key().to_hex();
+
+    let url = format!("{}/api/tokens", relay_http_url());
+    let body = serde_json::json!({
+        "name": "dedup-scopes-token",
+        // Intentionally duplicate messages:read.
+        "scopes": ["messages:read", "messages:read", "channels:read"],
+    });
+
+    let resp = client
+        .post(&url)
+        .header("X-Pubkey", &pubkey_hex)
+        .json(&body)
+        .send()
+        .await
+        .expect("POST /api/tokens failed");
+
+    assert_eq!(
+        resp.status(),
+        201,
+        "expected 201, got {}: {}",
+        resp.status(),
+        resp.text().await.unwrap_or_default()
+    );
+
+    let json: serde_json::Value = resp.json().await.expect("response JSON");
+    let scopes = json["scopes"].as_array().expect("scopes array");
+
+    // Collect unique scope strings to check deduplication regardless of order.
+    let scope_strs: Vec<&str> = scopes.iter().filter_map(|s| s.as_str()).collect();
+    let unique_count = {
+        let mut deduped = scope_strs.clone();
+        deduped.sort_unstable();
+        deduped.dedup();
+        deduped.len()
+    };
+
+    // Must have exactly 2 unique scopes (deduplication applied by handler).
+    assert_eq!(
+        unique_count, 2,
+        "expected 2 unique scopes after deduplication, got {} unique in {:?}",
+        unique_count, scope_strs
+    );
+
+    // Both unique scopes must be present.
+    assert!(
+        scope_strs.contains(&"messages:read"),
+        "messages:read should be in deduped scopes: {:?}",
+        scope_strs
+    );
+    assert!(
+        scope_strs.contains(&"channels:read"),
+        "channels:read should be in deduped scopes: {:?}",
+        scope_strs
+    );
+}
diff --git a/migrations/20260317000001_self_mint_token.sql b/migrations/20260317000001_self_mint_token.sql
new file mode 100644
index 0000000000..2dbac7b04f
--- /dev/null
+++ b/migrations/20260317000001_self_mint_token.sql
@@ -0,0 +1,6 @@
+-- Add created_by_self_mint column to api_tokens.
+-- Tracks whether a token was minted via POST /api/tokens (self-service)
+-- or via the sprout-admin CLI.
+ALTER TABLE api_tokens
+  ADD COLUMN created_by_self_mint TINYINT(1) NOT NULL DEFAULT 0
+    COMMENT '1 = minted via POST /api/tokens, 0 = minted via sprout-admin CLI';
