diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index c66affd5..ca01b55f 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -1,6 +1,6 @@
 use serde::{Deserialize, Serialize};
 use std::path::PathBuf;
-use tower_api::apis::configuration::Configuration;
+use tower_api::apis::configuration::{ApiKey, Configuration};
 use url::Url;
 
 mod error;
@@ -22,6 +22,9 @@ pub struct Config {
     #[serde(skip_serializing, skip_deserializing)]
     pub session: Option<Session>,
 
+    #[serde(skip_serializing, skip_deserializing)]
+    pub api_key: Option<String>,
+
     // cache_dir is the directory that we should cache uv artifacts within.
     pub cache_dir: Option<PathBuf>,
 }
@@ -33,6 +36,7 @@ impl Config {
             tower_url: default_tower_url(),
             json: false,
             session: None,
+            api_key: None,
             cache_dir: Some(default_cache_dir()),
         }
     }
@@ -44,12 +48,14 @@ impl Config {
         } else {
             default_tower_url()
         };
+        let api_key = std::env::var("TOWER_API_KEY").ok();
 
         Self {
             debug,
             tower_url,
             json: false,
             session: None,
+            api_key,
             cache_dir: Some(default_cache_dir()),
         }
     }
@@ -78,6 +84,7 @@ impl Config {
             tower_url: sess.tower_url.clone(),
             json: self.json,
             session: Some(sess),
+            api_key: self.api_key,
             cache_dir: Some(default_cache_dir()),
         }
     }
@@ -181,6 +188,15 @@ impl Config {
 
         configuration.base_path = base_path.to_string();
 
+        // API key takes precedence over session-based auth
+        if let Some(ref key) = self.api_key {
+            configuration.api_key = Some(ApiKey {
+                prefix: None,
+                key: key.clone(),
+            });
+            return configuration;
+        }
+
         // Always read from disk to pick up team switches
         if let Ok(session) = Session::from_config_dir() {
             if let Some(active_team) = &session.active_team {
diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs
index f4b0a3c0..d2e288cb 100644
--- a/crates/tower-cmd/src/api.rs
+++ b/crates/tower-cmd/src/api.rs
@@ -357,6 +357,22 @@ pub async fn refresh_session(
     .await
 }
 
+pub async fn list_teams(
+    config: &Config,
+) -> Result<
+    tower_api::models::ListTeamsResponse,
+    Error<tower_api::apis::default_api::ListTeamsError>,
+> {
+    let api_config = &config.into();
+
+    let params = tower_api::apis::default_api::ListTeamsParams {
+        page: None,
+        page_size: None,
+    };
+
+    unwrap_api_response(tower_api::apis::default_api::list_teams(api_config, params)).await
+}
+
 pub enum LogStreamEvent {
     EventLog(tower_api::models::RunLogLine),
     EventWarning(tower_api::models::EventWarning),
@@ -614,6 +630,17 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeSecretsKeySuccess
     }
 }
 
+impl ResponseEntity for tower_api::apis::default_api::ListTeamsSuccess {
+    type Data = tower_api::models::ListTeamsResponse;
+
+    fn extract_data(self) -> Option<Self::Data> {
+        match self {
+            Self::Status200(data) => Some(data),
+            Self::UnknownValue(_) => None,
+        }
+    }
+}
+
 impl ResponseEntity for tower_api::apis::default_api::ListAppsSuccess {
     type Data = tower_api::models::ListAppsResponse;
 
diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs
index 43de9c31..3af7c0be 100644
--- a/crates/tower-cmd/src/lib.rs
+++ b/crates/tower-cmd/src/lib.rs
@@ -30,10 +30,12 @@ impl App {
     pub fn new() -> Self {
         let cmd = root_cmd();
 
-        // In certain scenarios, we want to load a session from a JWT token supplied as an
-        // environment variable. This is for programmatic use cases where we want to test the CLI
-        // in automated environments, for instance.
-        let session = if let Ok(token) = std::env::var("TOWER_JWT") {
+        // When TOWER_API_KEY is set, skip session entirely — the API key is self-contained
+        // and authenticates via X-API-Key header rather than Bearer JWT.
+        let session = if std::env::var("TOWER_API_KEY").is_ok() {
+            None
+        } else if let Ok(token) = std::env::var("TOWER_JWT") {
+            // Load session from a JWT token for programmatic use cases
             Session::from_jwt(&token).ok()
         } else {
             Session::from_config_dir().ok()
diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs
index f41d9dc6..5ea1038b 100644
--- a/crates/tower-cmd/src/mcp.rs
+++ b/crates/tower-cmd/src/mcp.rs
@@ -334,6 +334,23 @@ impl TowerService {
             })
     }
 
+    async fn list_teams_via_api(&self) -> Result<CallToolResult, McpError> {
+        let response = api::list_teams(&self.config).await.map_err(|e| {
+            McpError::internal_error(
+                "Failed to list teams",
+                Some(json!({"error": e.to_string()})),
+            )
+        })?;
+
+        let teams: Vec<Value> = response
+            .teams
+            .into_iter()
+            .map(|team| json!({"name": team.name}))
+            .collect();
+
+        Self::json_success(json!({"teams": teams}))
+    }
+
     fn extract_api_error_message(error: &crate::Error) -> String {
         let crate::Error::ApiRunError { source } = error else {
             return error.to_string();
@@ -640,6 +657,10 @@ impl TowerService {
 
     #[tool(description = "List teams you belong to")]
     async fn tower_teams_list(&self) -> Result<CallToolResult, McpError> {
+        if self.config.api_key.is_some() {
+            return self.list_teams_via_api().await;
+        }
+
         let response = api::refresh_session(&self.config).await.map_err(|e| {
             McpError::internal_error(
                 "Failed to refresh session",
diff --git a/crates/tower-cmd/src/session.rs b/crates/tower-cmd/src/session.rs
index 8f0f8f24..65a6967a 100644
--- a/crates/tower-cmd/src/session.rs
+++ b/crates/tower-cmd/src/session.rs
@@ -1,5 +1,6 @@
 use crate::output;
 use clap::{Arg, ArgMatches, Command};
+use colored::Colorize;
 use config::{Config, Session};
 use tokio::{time, time::Duration};
 use tower_api::models::CreateDeviceLoginTicketResponse;
@@ -22,6 +23,22 @@ pub fn login_cmd() -> Command {
 pub async fn do_login(config: Config, args: &ArgMatches) {
     output::banner();
 
+    if std::env::var("TOWER_API_KEY").is_ok() {
+        output::write(&format!(
+            "{} TOWER_API_KEY is set. As long as this environment variable is present, \
+             the CLI will authenticate using the API key and ignore the session \
+             created by this login flow.\n",
+            "Warning:".yellow(),
+        ));
+
+        eprint!("Do you want to continue? [y/N] ");
+        let mut input = String::new();
+        if std::io::stdin().read_line(&mut input).is_err() || !input.trim().eq_ignore_ascii_case("y")
+        {
+            return;
+        }
+    }
+
     // Open a browser by default, unless the --no-browser flag is set.
     let open_browser = !args.get_flag("no-browser");
 
diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs
index 1a5a9174..a667baf4 100644
--- a/crates/tower-cmd/src/teams.rs
+++ b/crates/tower-cmd/src/teams.rs
@@ -48,8 +48,35 @@ async fn refresh_session(config: &Config) -> config::Session {
 }
 
 pub async fn do_list(config: Config) {
+    if config.api_key.is_some() {
+        do_list_via_api(&config).await;
+    } else {
+        do_list_via_session(&config).await;
+    }
+}
+
+async fn do_list_via_api(config: &Config) {
+    let resp = output::with_spinner("Fetching teams", api::list_teams(config)).await;
+
+    let headers = vec!["Name"]
+        .into_iter()
+        .map(|h| h.yellow().to_string())
+        .collect();
+
+    let teams_data: Vec<Vec<String>> = resp
+        .teams
+        .iter()
+        .map(|team| vec![team.name.clone()])
+        .collect();
+
+    output::newline();
+    output::table(headers, teams_data, None::<&Vec<config::Team>>);
+    output::newline();
+}
+
+async fn do_list_via_session(config: &Config) {
     // Refresh the session and get the updated data
-    let session = refresh_session(&config).await;
+    let session = refresh_session(config).await;
 
     // Get the current active team from the session
     let active_team = session.active_team.clone();
diff --git a/tests/integration/features/cli_api_key_auth.feature b/tests/integration/features/cli_api_key_auth.feature
new file mode 100644
index 00000000..c559c819
--- /dev/null
+++ b/tests/integration/features/cli_api_key_auth.feature
@@ -0,0 +1,18 @@
+Feature: API Key Authentication
+  As a user authenticating with an API key
+  I want the CLI to use the X-API-Key header
+  So that I can interact with Tower without a session
+
+  Scenario: List apps using API key auth
+    When I run "tower --json apps list" via CLI with API key
+    Then the output should be valid JSON
+    And no session.json should exist in the temp home
+
+  Scenario: List teams using API key auth
+    When I run "tower teams list" via CLI with API key
+    Then no session.json should exist in the temp home
+
+  Scenario: Login warns when API key is set
+    When I run "tower login --no-browser" via CLI with API key
+    Then the output should show "TOWER_API_KEY is set"
+    And the output should show "ignore the session"
diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py
index e0b71da6..92d421a4 100644
--- a/tests/integration/features/steps/cli_steps.py
+++ b/tests/integration/features/steps/cli_steps.py
@@ -57,6 +57,54 @@ def step_run_cli_command(context, command):
         raise
 
 
+@step('I run "{command}" via CLI with API key')
+def step_run_cli_command_with_api_key(context, command):
+    """Run a Tower CLI command authenticating via TOWER_API_KEY instead of session.json"""
+    cli_path = context.tower_binary
+
+    cmd_parts = shlex.split(command)
+    full_command = [cli_path] + cmd_parts[1:]  # Skip 'tower' prefix
+
+    try:
+        test_env = os.environ.copy()
+        test_env["FORCE_COLOR"] = "1"
+        test_env["CLICOLOR_FORCE"] = "1"
+        test_env["TOWER_URL"] = context.tower_url
+        test_env["TOWER_API_KEY"] = "sk-test-api-key"
+
+        # Use a temp HOME with no session.json to prove API key auth works standalone
+        test_env["HOME"] = context.temp_dir
+
+        result = subprocess.run(
+            full_command,
+            capture_output=True,
+            text=True,
+            timeout=60,
+            env=test_env,
+        )
+        context.cli_output = result.stdout + result.stderr
+        context.cli_stdout = result.stdout
+        context.cli_return_code = result.returncode
+    except subprocess.TimeoutExpired:
+        context.cli_output = "Command timed out"
+        context.cli_stdout = ""
+        context.cli_return_code = 124
+    except Exception as e:
+        print(f"DEBUG: Exception in CLI command: {type(e).__name__}: {e}")
+        print(f"DEBUG: Command was: {full_command}")
+        print(f"DEBUG: Working directory: {os.getcwd()}")
+        raise
+
+
+@step("no session.json should exist in the temp home")
+def step_no_session_json(context):
+    """Verify that API key auth did not create a session.json file"""
+    session_path = Path(context.temp_dir) / ".config" / "tower" / "session.json"
+    assert (
+        not session_path.exists()
+    ), f"session.json should not exist but found at {session_path}"
+
+
 @step('I run "{command}" via CLI using created app name')
 def step_run_cli_command_with_app_name(context, command):
     """Run a Tower CLI command with the generated app name injected."""
