diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs
index 0e6afce2..98fc2529 100644
--- a/src/api/admin/api_server.rs
+++ b/src/api/admin/api_server.rs
@@ -1,8 +1,6 @@
 use std::borrow::Cow;
 use std::sync::Arc;
 
-use argon2::password_hash::PasswordHash;
-
 use http::header::{HeaderValue, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION};
 use hyper::{body::Incoming as IncomingBody, Request, Response};
 use serde::{Deserialize, Serialize};
@@ -15,10 +13,12 @@ use opentelemetry_prometheus::PrometheusExporter;
 
 use garage_model::garage::Garage;
 use garage_rpc::{Endpoint as RpcEndpoint, *};
+use garage_table::EmptyKey;
 use garage_util::background::BackgroundRunner;
 use garage_util::data::Uuid;
 use garage_util::error::Error as GarageError;
 use garage_util::socket_address::UnixOrTCPSocketAddress;
+use garage_util::time::now_msec;
 
 use garage_api_common::generic_server::*;
 use garage_api_common::helpers::*;
@@ -168,14 +168,13 @@ impl AdminApiServer {
 				},
 			};
 
-		if let Some(password_hash) = required_auth_hash {
-			match auth_header {
-				None => return Err(Error::forbidden("Authorization token must be provided")),
-				Some(authorization) => {
-					verify_bearer_token(&authorization, password_hash)?;
-				}
-			}
-		}
+		verify_authorization(
+			&self.garage,
+			required_auth_hash,
+			auth_header,
+			request.name(),
+		)
+		.await?;
 
 		match request {
 			AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await,
@@ -249,20 +248,65 @@ fn hash_bearer_token(token: &str) -> String {
 		.to_string()
 }
 
-fn verify_bearer_token(token: &hyper::http::HeaderValue, password_hash: &str) -> Result<(), Error> {
-	use argon2::{password_hash::PasswordVerifier, Argon2};
+async fn verify_authorization(
+	garage: &Garage,
+	required_token_hash: Option<&str>,
+	auth_header: Option<hyper::http::HeaderValue>,
+	endpoint_name: &str,
+) -> Result<(), Error> {
+	use argon2::{password_hash::PasswordHash, password_hash::PasswordVerifier, Argon2};
 
-	let parsed_hash = PasswordHash::new(&password_hash).unwrap();
+	let invalid_msg = "Invalid bearer token";
 
-	token
-		.to_str()?
-		.strip_prefix("Bearer ")
-		.and_then(|token| {
-			Argon2::default()
-				.verify_password(token.trim().as_bytes(), &parsed_hash)
-				.ok()
-		})
-		.ok_or_else(|| Error::forbidden("Invalid authorization token"))?;
+	if let Some(token_hash_str) = required_token_hash {
+		let token = match &auth_header {
+			None => {
+				return Err(Error::forbidden(
+					"Bearer token must be provided in Authorization header",
+				))
+			}
+			Some(authorization) => authorization
+				.to_str()?
+				.strip_prefix("Bearer ")
+				.ok_or_else(|| Error::forbidden("Invalid Authorization header"))?
+				.trim(),
+		};
+
+		let token_hash_string = if let Some((prefix, _)) = token.split_once('.') {
+			garage
+				.admin_token_table
+				.get(&EmptyKey, &prefix.to_string())
+				.await?
+				.and_then(|k| k.state.into_option())
+				.filter(|p| {
+					p.expiration
+						.get()
+						.map(|exp| now_msec() < exp)
+						.unwrap_or(true)
+				})
+				.filter(|p| {
+					p.scope
+						.get()
+						.0
+						.iter()
+						.any(|x| x == "*" || x == endpoint_name)
+				})
+				.ok_or_else(|| Error::forbidden(invalid_msg))?
+				.token_hash
+		} else {
+			token_hash_str.to_string()
+		};
+
+		let token_hash = PasswordHash::new(&token_hash_string)
+			.ok_or_internal_error("Could not parse token hash")?;
+
+		if Argon2::default()
+			.verify_password(token.as_bytes(), &token_hash)
+			.is_err()
+		{
+			return Err(Error::forbidden(invalid_msg));
+		}
+	}
 
 	Ok(())
 }