diff --git a/Cargo.lock b/Cargo.lock
index 11c7c30..4e93720 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -291,6 +291,7 @@ dependencies = [
  "awc",
  "background-jobs",
  "base64",
+ "bcrypt",
  "clap",
  "config",
  "console-subscriber",
@@ -544,6 +545,18 @@ version = "1.5.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf"
 
+[[package]]
+name = "bcrypt"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7e7c93a3fb23b2fdde989b2c9ec4dd153063ec81f408507f84c090cd91c6641"
+dependencies = [
+ "base64",
+ "blowfish",
+ "getrandom",
+ "zeroize",
+]
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -559,6 +572,16 @@ dependencies = [
  "generic-array",
 ]
 
+[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
 [[package]]
 name = "bumpalo"
 version = "3.11.1"
@@ -614,6 +637,16 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "cipher"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
 [[package]]
 name = "clap"
 version = "4.0.26"
@@ -1313,6 +1346,15 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inout"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "instant"
 version = "0.1.12"
diff --git a/Cargo.toml b/Cargo.toml
index b35191e..4aed898 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,6 +29,7 @@ activitystreams = "0.7.0-alpha.19"
 activitystreams-ext = "0.1.0-alpha.2"
 ammonia = "3.1.0"
 awc = { version = "3.0.0", default-features = false, features = ["rustls"] }
+bcrypt = "0.13"
 base64 = "0.13"
 clap = { version = "4.0.0", features = ["derive"] }
 config = "0.13.0"
diff --git a/src/admin.rs b/src/admin.rs
new file mode 100644
index 0000000..c3fb836
--- /dev/null
+++ b/src/admin.rs
@@ -0,0 +1,24 @@
+use activitystreams::iri_string::types::IriString;
+
+pub mod client;
+pub mod routes;
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub(crate) struct Domains {
+    domains: Vec<String>,
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub(crate) struct AllowedDomains {
+    allowed_domains: Vec<String>,
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub(crate) struct BlockedDomains {
+    blocked_domains: Vec<String>,
+}
+
+#[derive(serde::Deserialize, serde::Serialize)]
+pub(crate) struct ConnectedActors {
+    connected_actors: Vec<IriString>,
+}
diff --git a/src/admin/client.rs b/src/admin/client.rs
new file mode 100644
index 0000000..3bcfc8b
--- /dev/null
+++ b/src/admin/client.rs
@@ -0,0 +1,87 @@
+use crate::{
+    admin::{AllowedDomains, BlockedDomains, ConnectedActors, Domains},
+    config::{AdminUrlKind, Config},
+    error::{Error, ErrorKind},
+};
+use awc::Client;
+use serde::de::DeserializeOwned;
+
+pub(crate) async fn allow(
+    client: &Client,
+    config: &Config,
+    domains: Vec<String>,
+) -> Result<(), Error> {
+    post_domains(client, config, domains, AdminUrlKind::Allow).await
+}
+
+pub(crate) async fn block(
+    client: &Client,
+    config: &Config,
+    domains: Vec<String>,
+) -> Result<(), Error> {
+    post_domains(client, config, domains, AdminUrlKind::Block).await
+}
+
+pub(crate) async fn allowed(client: &Client, config: &Config) -> Result<AllowedDomains, Error> {
+    get_results(client, config, AdminUrlKind::Allowed).await
+}
+
+pub(crate) async fn blocked(client: &Client, config: &Config) -> Result<BlockedDomains, Error> {
+    get_results(client, config, AdminUrlKind::Blocked).await
+}
+
+pub(crate) async fn connected(client: &Client, config: &Config) -> Result<ConnectedActors, Error> {
+    get_results(client, config, AdminUrlKind::Connected).await
+}
+
+async fn get_results<T: DeserializeOwned>(
+    client: &Client,
+    config: &Config,
+    url_kind: AdminUrlKind,
+) -> Result<T, Error> {
+    let x_api_token = config.x_api_token().ok_or(ErrorKind::MissingApiToken)?;
+
+    let iri = config.generate_admin_url(url_kind);
+
+    let mut res = client
+        .get(iri.as_str())
+        .insert_header(x_api_token)
+        .send()
+        .await
+        .map_err(|e| ErrorKind::SendRequest(iri.to_string(), e.to_string()))?;
+
+    if !res.status().is_success() {
+        return Err(ErrorKind::Status(iri.to_string(), res.status()).into());
+    }
+
+    let t = res
+        .json()
+        .await
+        .map_err(|e| ErrorKind::ReceiveResponse(iri.to_string(), e.to_string()))?;
+
+    Ok(t)
+}
+
+async fn post_domains(
+    client: &Client,
+    config: &Config,
+    domains: Vec<String>,
+    url_kind: AdminUrlKind,
+) -> Result<(), Error> {
+    let x_api_token = config.x_api_token().ok_or(ErrorKind::MissingApiToken)?;
+
+    let iri = config.generate_admin_url(url_kind);
+
+    let res = client
+        .post(iri.as_str())
+        .insert_header(x_api_token)
+        .send_json(&Domains { domains })
+        .await
+        .map_err(|e| ErrorKind::SendRequest(iri.to_string(), e.to_string()))?;
+
+    if !res.status().is_success() {
+        tracing::warn!("Failed to allow domains");
+    }
+
+    Ok(())
+}
diff --git a/src/admin/routes.rs b/src/admin/routes.rs
new file mode 100644
index 0000000..68b78a7
--- /dev/null
+++ b/src/admin/routes.rs
@@ -0,0 +1,42 @@
+use crate::{
+    admin::{AllowedDomains, BlockedDomains, ConnectedActors, Domains},
+    error::Error,
+    extractors::Admin,
+};
+use actix_web::{web::Json, HttpResponse};
+
+pub(crate) async fn allow(
+    admin: Admin,
+    Json(Domains { domains }): Json<Domains>,
+) -> Result<HttpResponse, Error> {
+    admin.db_ref().add_allows(domains).await?;
+
+    Ok(HttpResponse::NoContent().finish())
+}
+
+pub(crate) async fn block(
+    admin: Admin,
+    Json(Domains { domains }): Json<Domains>,
+) -> Result<HttpResponse, Error> {
+    admin.db_ref().add_blocks(domains).await?;
+
+    Ok(HttpResponse::NoContent().finish())
+}
+
+pub(crate) async fn allowed(admin: Admin) -> Result<HttpResponse, Error> {
+    let allowed_domains = admin.db_ref().allowed_domains().await?;
+
+    Ok(HttpResponse::Ok().json(AllowedDomains { allowed_domains }))
+}
+
+pub(crate) async fn blocked(admin: Admin) -> Result<HttpResponse, Error> {
+    let blocked_domains = admin.db_ref().blocks().await?;
+
+    Ok(HttpResponse::Ok().json(BlockedDomains { blocked_domains }))
+}
+
+pub(crate) async fn connected(admin: Admin) -> Result<HttpResponse, Error> {
+    let connected_actors = admin.db_ref().connected_ids().await?;
+
+    Ok(HttpResponse::Ok().json(ConnectedActors { connected_actors }))
+}
diff --git a/src/config.rs b/src/config.rs
index e42b755..750a443 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,6 +1,7 @@
 use crate::{
     data::{ActorCache, State},
     error::Error,
+    extractors::{AdminConfig, XApiToken},
     middleware::MyVerify,
     requests::Requests,
 };
@@ -32,6 +33,7 @@ pub(crate) struct ParsedConfig {
     opentelemetry_url: Option<IriString>,
     telegram_token: Option<String>,
     telegram_admin_handle: Option<String>,
+    api_token: Option<String>,
 }
 
 #[derive(Clone)]
@@ -49,6 +51,7 @@ pub struct Config {
     opentelemetry_url: Option<IriString>,
     telegram_token: Option<String>,
     telegram_admin_handle: Option<String>,
+    api_token: Option<String>,
 }
 
 #[derive(Debug)]
@@ -65,6 +68,15 @@ pub enum UrlKind {
     Outbox,
 }
 
+#[derive(Debug)]
+pub enum AdminUrlKind {
+    Allow,
+    Block,
+    Allowed,
+    Blocked,
+    Connected,
+}
+
 impl std::fmt::Debug for Config {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("Config")
@@ -84,6 +96,7 @@ impl std::fmt::Debug for Config {
             )
             .field("telegram_token", &"[redacted]")
             .field("telegram_admin_handle", &self.telegram_admin_handle)
+            .field("api_token", &"[redacted]")
             .finish()
     }
 }
@@ -93,7 +106,7 @@ impl Config {
         let config = config::Config::builder()
             .set_default("hostname", "localhost:8080")?
             .set_default("addr", "127.0.0.1")?
-            .set_default::<_, u64>("port", 8080)?
+            .set_default("port", 8080u64)?
             .set_default("debug", true)?
             .set_default("restricted_mode", false)?
             .set_default("validate_signatures", false)?
@@ -104,6 +117,7 @@ impl Config {
             .set_default("opentelemetry_url", None as Option<&str>)?
             .set_default("telegram_token", None as Option<&str>)?
             .set_default("telegram_admin_handle", None as Option<&str>)?
+            .set_default("api_token", None as Option<&str>)?
             .add_source(Environment::default())
             .build()?;
 
@@ -126,6 +140,7 @@ impl Config {
             opentelemetry_url: config.opentelemetry_url,
             telegram_token: config.telegram_token,
             telegram_admin_handle: config.telegram_admin_handle,
+            api_token: config.api_token,
         })
     }
 
@@ -158,6 +173,24 @@ impl Config {
         }
     }
 
+    pub(crate) fn x_api_token(&self) -> Option<XApiToken> {
+        self.api_token.clone().map(XApiToken::new)
+    }
+
+    pub(crate) fn admin_config(&self) -> Option<actix_web::web::Data<AdminConfig>> {
+        if let Some(api_token) = &self.api_token {
+            match AdminConfig::build(api_token) {
+                Ok(conf) => Some(actix_web::web::Data::new(conf)),
+                Err(e) => {
+                    tracing::error!("Error creating admin config: {}", e);
+                    None
+                }
+            }
+        } else {
+            None
+        }
+    }
+
     pub(crate) fn bind_address(&self) -> (IpAddr, u16) {
         (self.addr, self.port)
     }
@@ -281,4 +314,26 @@ impl Config {
 
         Ok(iri)
     }
+
+    pub(crate) fn generate_admin_url(&self, kind: AdminUrlKind) -> IriString {
+        self.do_generate_admin_url(kind)
+            .expect("Generated valid IRI")
+    }
+
+    fn do_generate_admin_url(&self, kind: AdminUrlKind) -> Result<IriString, Error> {
+        let iri = match kind {
+            AdminUrlKind::Allow => FixedBaseResolver::new(self.base_uri.as_ref())
+                .try_resolve(IriRelativeStr::new("api/v1/admin/allow")?.as_ref())?,
+            AdminUrlKind::Block => FixedBaseResolver::new(self.base_uri.as_ref())
+                .try_resolve(IriRelativeStr::new("api/v1/admin/block")?.as_ref())?,
+            AdminUrlKind::Allowed => FixedBaseResolver::new(self.base_uri.as_ref())
+                .try_resolve(IriRelativeStr::new("api/v1/admin/allowed")?.as_ref())?,
+            AdminUrlKind::Blocked => FixedBaseResolver::new(self.base_uri.as_ref())
+                .try_resolve(IriRelativeStr::new("api/v1/admin/blocked")?.as_ref())?,
+            AdminUrlKind::Connected => FixedBaseResolver::new(self.base_uri.as_ref())
+                .try_resolve(IriRelativeStr::new("api/v1/admin/connected")?.as_ref())?,
+        };
+
+        Ok(iri)
+    }
 }
diff --git a/src/error.rs b/src/error.rs
index 0b7716b..eb602b0 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -180,6 +180,9 @@ pub(crate) enum ErrorKind {
 
     #[error("Failed to extract fields from {0}")]
     Extract(&'static str),
+
+    #[error("No API Token supplied")]
+    MissingApiToken,
 }
 
 impl ResponseError for Error {
diff --git a/src/extractors.rs b/src/extractors.rs
new file mode 100644
index 0000000..742f016
--- /dev/null
+++ b/src/extractors.rs
@@ -0,0 +1,194 @@
+use actix_web::{
+    dev::Payload,
+    error::ParseError,
+    http::{
+        header::{from_one_raw_str, Header, HeaderName, HeaderValue, TryIntoHeaderValue},
+        StatusCode,
+    },
+    web::Data,
+    FromRequest, HttpMessage, HttpRequest, HttpResponse, ResponseError,
+};
+use bcrypt::{BcryptError, DEFAULT_COST};
+use http_signature_normalization_actix::prelude::InvalidHeaderValue;
+use std::{
+    convert::Infallible,
+    future::{ready, Ready},
+    str::FromStr,
+};
+use tracing_error::SpanTrace;
+
+use crate::db::Db;
+
+#[derive(Clone)]
+pub(crate) struct AdminConfig {
+    hashed_api_token: String,
+}
+
+impl AdminConfig {
+    pub(crate) fn build(api_token: &str) -> Result<Self, Error> {
+        Ok(AdminConfig {
+            hashed_api_token: bcrypt::hash(api_token, DEFAULT_COST).map_err(Error::bcrypt_hash)?,
+        })
+    }
+
+    fn verify(&self, token: XApiToken) -> Result<bool, Error> {
+        Ok(bcrypt::verify(&self.hashed_api_token, &token.0).map_err(Error::bcrypt_verify)?)
+    }
+}
+
+pub(crate) struct Admin {
+    db: Data<Db>,
+}
+
+impl Admin {
+    #[tracing::instrument(level = "debug", skip(req))]
+    fn verify(req: &HttpRequest) -> Result<Self, Error> {
+        let hashed_api_token = req
+            .app_data::<Data<AdminConfig>>()
+            .ok_or_else(Error::missing_config)?;
+
+        let x_api_token = XApiToken::parse(req).map_err(Error::parse_header)?;
+
+        if hashed_api_token.verify(x_api_token)? {
+            let db = req.app_data::<Data<Db>>().ok_or_else(Error::missing_db)?;
+
+            return Ok(Self { db: db.clone() });
+        }
+
+        Err(Error::invalid())
+    }
+
+    pub(crate) fn db_ref(&self) -> &Db {
+        &self.db
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error("Failed authentication")]
+pub(crate) struct Error {
+    context: SpanTrace,
+    #[source]
+    kind: ErrorKind,
+}
+
+impl Error {
+    fn invalid() -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::Invalid,
+        }
+    }
+
+    fn missing_config() -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::MissingConfig,
+        }
+    }
+
+    fn missing_db() -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::MissingDb,
+        }
+    }
+
+    fn bcrypt_verify(e: BcryptError) -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::BCryptVerify(e),
+        }
+    }
+
+    fn bcrypt_hash(e: BcryptError) -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::BCryptHash(e),
+        }
+    }
+
+    fn parse_header(e: ParseError) -> Self {
+        Error {
+            context: SpanTrace::capture(),
+            kind: ErrorKind::ParseHeader(e),
+        }
+    }
+}
+
+#[derive(Debug, thiserror::Error)]
+enum ErrorKind {
+    #[error("Invalid API Token")]
+    Invalid,
+
+    #[error("Missing Config")]
+    MissingConfig,
+
+    #[error("Missing Db")]
+    MissingDb,
+
+    #[error("Verifying")]
+    BCryptVerify(#[source] BcryptError),
+
+    #[error("Hashing")]
+    BCryptHash(#[source] BcryptError),
+
+    #[error("Parse Header")]
+    ParseHeader(#[source] ParseError),
+}
+
+impl ResponseError for Error {
+    fn status_code(&self) -> StatusCode {
+        match self.kind {
+            ErrorKind::Invalid | ErrorKind::ParseHeader(_) => StatusCode::BAD_REQUEST,
+            _ => StatusCode::INTERNAL_SERVER_ERROR,
+        }
+    }
+
+    fn error_response(&self) -> HttpResponse {
+        HttpResponse::build(self.status_code())
+            .json(serde_json::json!({ "msg": self.kind.to_string() }))
+    }
+}
+
+impl FromRequest for Admin {
+    type Error = Error;
+    type Future = Ready<Result<Self, Self::Error>>;
+
+    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
+        ready(Admin::verify(req))
+    }
+}
+
+pub(crate) struct XApiToken(String);
+
+impl XApiToken {
+    pub(crate) fn new(token: String) -> Self {
+        Self(token)
+    }
+}
+
+impl Header for XApiToken {
+    fn name() -> HeaderName {
+        HeaderName::from_static("x-api-token")
+    }
+
+    fn parse<M: HttpMessage>(msg: &M) -> Result<Self, ParseError> {
+        from_one_raw_str(msg.headers().get(Self::name()))
+    }
+}
+
+impl TryIntoHeaderValue for XApiToken {
+    type Error = InvalidHeaderValue;
+
+    fn try_into_value(self) -> Result<HeaderValue, Self::Error> {
+        HeaderValue::from_str(&self.0)
+    }
+}
+
+impl FromStr for XApiToken {
+    type Err = Infallible;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Ok(XApiToken(s.to_string()))
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 3302bc4..1ef2cd9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,12 +12,14 @@ use tracing_error::ErrorLayer;
 use tracing_log::LogTracer;
 use tracing_subscriber::{filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, Layer};
 
+mod admin;
 mod apub;
 mod args;
 mod config;
 mod data;
 mod db;
 mod error;
+mod extractors;
 mod jobs;
 mod middleware;
 mod requests;
@@ -135,15 +137,22 @@ async fn main() -> Result<(), anyhow::Error> {
 
     let bind_address = config.bind_address();
     HttpServer::new(move || {
-        App::new()
-            .wrap(TracingLogger::default())
+        let app = App::new()
             .app_data(web::Data::new(db.clone()))
             .app_data(web::Data::new(state.clone()))
             .app_data(web::Data::new(state.requests(&config)))
             .app_data(web::Data::new(actors.clone()))
             .app_data(web::Data::new(config.clone()))
             .app_data(web::Data::new(job_server.clone()))
-            .app_data(web::Data::new(media.clone()))
+            .app_data(web::Data::new(media.clone()));
+
+        let app = if let Some(data) = config.admin_config() {
+            app.app_data(data)
+        } else {
+            app
+        };
+
+        app.wrap(TracingLogger::default())
             .service(web::resource("/").route(web::get().to(index)))
             .service(web::resource("/media/{path}").route(web::get().to(routes::media)))
             .service(
@@ -165,6 +174,16 @@ async fn main() -> Result<(), anyhow::Error> {
                     .service(web::resource("/nodeinfo").route(web::get().to(nodeinfo_meta))),
             )
             .service(web::resource("/static/{filename}").route(web::get().to(statics)))
+            .service(
+                web::scope("/api/v1").service(
+                    web::scope("/admin")
+                        .route("/allow", web::post().to(admin::routes::allow))
+                        .route("/block", web::post().to(admin::routes::block))
+                        .route("/allowed", web::get().to(admin::routes::allowed))
+                        .route("/blocked", web::get().to(admin::routes::blocked))
+                        .route("/connected", web::get().to(admin::routes::connected)),
+                ),
+            )
     })
     .bind(bind_address)?
     .run()