diff --git a/doc/book/reference-manual/configuration.md b/doc/book/reference-manual/configuration.md
index e0fc17bc..6e4daea0 100644
--- a/doc/book/reference-manual/configuration.md
+++ b/doc/book/reference-manual/configuration.md
@@ -80,6 +80,7 @@ add_host_to_metrics = true
 [admin]
 api_bind_addr = "0.0.0.0:3903"
 metrics_token = "BCAdFjoa9G0KJR0WXnHHm7fs1ZAbfpI8iIZ+Z/a2NgI="
+metrics_require_token = true
 admin_token = "UkLeGWEvHnXBqnueR3ISEMWpOnm40jH2tM2HnnL/0F4="
 trace_sink = "http://localhost:4317"
 ```
@@ -145,6 +146,7 @@ The `[s3_web]` section:
 
 The `[admin]` section:
 [`api_bind_addr`](#admin_api_bind_addr),
+[`metrics_require_token`](#admin_metrics_require_token),
 [`metrics_token`/`metrics_token_file`](#admin_metrics_token),
 [`admin_token`/`admin_token_file`](#admin_token),
 [`trace_sink`](#admin_trace_sink),
@@ -767,10 +769,34 @@ See [administration API reference](@/documentation/reference-manual/admin-api.md
 Alternatively, since `v0.8.5`, a path can be used to create a unix socket. Note that for security reasons,
 the socket will have 0220 mode. Make sure to set user and group permissions accordingly.
 
+#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token}
+
+The token for accessing all administration functions on the admin endpoint,
+with the exception of the metrics endpoint (see `metrics_token`).
+
+You can use any random string for this value. We recommend generating a random
+token with `openssl rand -base64 32`.
+
+For Garage version earlier than `v2.0`, if this token is not set,
+access to these endpoints is disabled entirely.
+
+Since Garage `v2.0`, additional admin API tokens can be defined dynamically
+in your Garage cluster using administration commands. This new admin token system
+is more flexible since it allows admin tokens to have an expiration date,
+and to have a scope restricted to certain admin API functions. If `admin_token`
+is set, it behaves as an admin token without expiration and with full scope.
+Otherwise, only admin API tokens defined dynamically can be used.
+
+`admin_token` was introduced in Garage `v0.7.2`.
+`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`.
+
+`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`.
+
 #### `metrics_token`, `metrics_token_file` or `GARAGE_METRICS_TOKEN`, `GARAGE_METRICS_TOKEN_FILE` (env) {#admin_metrics_token}
 
-The token for accessing the Metrics endpoint. If this token is not set, the
-Metrics endpoint can be accessed without access control.
+The token for accessing the Prometheus metrics endpoint (`/metrics`).
+If this token is not set, and unless `metrics_require_token` is set to `true`,
+the metrics endpoint can be accessed without access control.
 
 You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`.
 
@@ -779,17 +805,12 @@ You can use any random string for this value. We recommend generating a random t
 
 `GARAGE_METRICS_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`.
 
-#### `admin_token`, `admin_token_file` or `GARAGE_ADMIN_TOKEN`, `GARAGE_ADMIN_TOKEN_FILE` (env) {#admin_token}
+#### `metrics_require_token` (since `v2.0.0`) {#admin_metrics_require_token}
 
-The token for accessing all of the other administration endpoints.  If this
-token is not set, access to these endpoints is disabled entirely.
-
-You can use any random string for this value. We recommend generating a random token with `openssl rand -base64 32`.
-
-`admin_token` was introduced in Garage `v0.7.2`.
-`admin_token_file` and the `GARAGE_ADMIN_TOKEN` environment variable are supported since Garage `v0.8.2`.
-
-`GARAGE_ADMIN_TOKEN_FILE` is supported since `v0.8.5` / `v0.9.1`.
+If this is set to `true`, accessing the metrics endpoint will always require
+an access token. Valid tokens include the `metrics_token` if it is set,
+and admin API token defined dynamicaly in Garage which have
+the `Metrics` endpoint in their scope.
 
 #### `trace_sink` {#admin_trace_sink}
 
diff --git a/src/api/admin/api_server.rs b/src/api/admin/api_server.rs
index 98fc2529..a214dfa7 100644
--- a/src/api/admin/api_server.rs
+++ b/src/api/admin/api_server.rs
@@ -99,6 +99,7 @@ pub struct AdminApiServer {
 	#[cfg(feature = "metrics")]
 	pub(crate) exporter: PrometheusExporter,
 	metrics_token: Option<String>,
+	metrics_require_token: bool,
 	admin_token: Option<String>,
 	pub(crate) background: Arc<BackgroundRunner>,
 	pub(crate) endpoint: Arc<RpcEndpoint<AdminRpc, Self>>,
@@ -118,6 +119,7 @@ impl AdminApiServer {
 		let cfg = &garage.config.admin;
 		let metrics_token = cfg.metrics_token.as_deref().map(hash_bearer_token);
 		let admin_token = cfg.admin_token.as_deref().map(hash_bearer_token);
+		let metrics_require_token = cfg.metrics_require_token;
 
 		let endpoint = garage.system.netapp.endpoint(ADMIN_RPC_PATH.into());
 		let admin = Arc::new(Self {
@@ -125,6 +127,7 @@ impl AdminApiServer {
 			#[cfg(feature = "metrics")]
 			exporter,
 			metrics_token,
+			metrics_require_token,
 			admin_token,
 			background,
 			endpoint,
@@ -156,25 +159,19 @@ impl AdminApiServer {
 			HttpEndpoint::New(_) => AdminApiRequest::from_request(req).await?,
 		};
 
-		let required_auth_hash =
-			match request.authorization_type() {
-				Authorization::None => None,
-				Authorization::MetricsToken => self.metrics_token.as_deref(),
-				Authorization::AdminToken => match self.admin_token.as_deref() {
-					None => return Err(Error::forbidden(
-						"Admin token isn't configured, admin API access is disabled for security.",
-					)),
-					Some(t) => Some(t),
-				},
-			};
+		let (global_token_hash, token_required) = match request.authorization_type() {
+			Authorization::None => (None, false),
+			Authorization::MetricsToken => (
+				self.metrics_token.as_deref(),
+				self.metrics_token.is_some() || self.metrics_require_token,
+			),
+			Authorization::AdminToken => (self.admin_token.as_deref(), true),
+		};
 
-		verify_authorization(
-			&self.garage,
-			required_auth_hash,
-			auth_header,
-			request.name(),
-		)
-		.await?;
+		if token_required {
+			verify_authorization(&self.garage, global_token_hash, auth_header, request.name())
+				.await?;
+		}
 
 		match request {
 			AdminApiRequest::Options(req) => req.handle(&self.garage, &self).await,
@@ -250,7 +247,7 @@ fn hash_bearer_token(token: &str) -> String {
 
 async fn verify_authorization(
 	garage: &Garage,
-	required_token_hash: Option<&str>,
+	global_token_hash: Option<&str>,
 	auth_header: Option<hyper::http::HeaderValue>,
 	endpoint_name: &str,
 ) -> Result<(), Error> {
@@ -258,55 +255,52 @@ async fn verify_authorization(
 
 	let invalid_msg = "Invalid bearer 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));
+	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 {
+		global_token_hash
+			.ok_or_else(|| Error::forbidden(invalid_msg))?
+			.to_string()
+	};
+
+	let token_hash =
+		PasswordHash::new(&token_hash_string).ok_or_internal_error("Could not parse token hash")?;
+
+	Argon2::default()
+		.verify_password(token.as_bytes(), &token_hash)
+		.map_err(|_| Error::forbidden(invalid_msg))?;
 
 	Ok(())
 }
diff --git a/src/util/config.rs b/src/util/config.rs
index 73fc4ff4..47247718 100644
--- a/src/util/config.rs
+++ b/src/util/config.rs
@@ -198,6 +198,9 @@ pub struct AdminConfig {
 	pub metrics_token: Option<String>,
 	/// File to read metrics token from
 	pub metrics_token_file: Option<PathBuf>,
+	/// Whether to require an access token for accessing the metrics endpoint
+	#[serde(default)]
+	pub metrics_require_token: bool,
 
 	/// Bearer token to use to access Admin API endpoints
 	pub admin_token: Option<String>,