Airflow CTL Version
Latest stable version
Airflow CTL Command
airflowctl auth login --env staging --api-url --api-token ; airflowctl dags list --env staging
Keyring Backend / Version
macOS Keychain (keyring.backends.macOS.Keyring) 25.7.0
Auth Type
Token
What is the current behaviour?
--env/-e is accepted by every non-auth command (dags list, dags trigger, connections list, etc. — anything routed through output_command_list / ARG_AUTH_ENVIRONMENT) and parses fine into args.env. But it's never actually used.
provide_api_client's wrapper only forwards api_token to get_client():
# airflow-ctl/src/airflowctl/api/client.py
def wrapper(*args, **kwargs) -> RT:
if "api_client" not in kwargs:
api_token = getattr(args[0], "api_token", None) if args else None
with get_client(kind=kind, api_token=api_token) as api_client:
return func(*args, api_client=api_client, **kwargs)
and get_client() constructs Credentials with no api_environment:
def get_client(kind=ClientKind.CLI, api_token=None):
...
credentials = Credentials(client_kind=kind, api_token=api_token).load()
Credentials.__init__ defaults api_environment to "production". Since it's never overridden here, every non-auth command silently operates against the production environment — reading production.json for the API URL and the api_token_production keychain entry — no matter what --env you pass.
auth login doesn't go through this path; it builds Credentials(..., api_environment=args.env) directly, so login correctly targets the requested environment and stores the token under the right keychain key. The net effect: auth login --env staging succeeds and looks correct, but every subsequent command (dags list --env staging, etc.) silently talks to production instead, using a stale/unrelated production token — surfacing as {'detail': 'Token Expired'} or {'detail': 'Invalid JWT token'} with no indication that the wrong environment was ever contacted.
Reproduced: I confirmed by instrumenting Credentials(client_kind=ClientKind.CLI, api_token=None).load() exactly as get_client calls it — with sys.argv set to dags list --env staging, it resolves api_environment="production", api_url to the production URL, and api_token to the production keychain entry.
Workaround: setting AIRFLOW_CLI_ENVIRONMENT=staging in the environment (read directly via os.getenv in Credentials.__init__, independent of the CLI arg) makes commands correctly target the intended environment. --api-token alone does not work around this, since it only overrides the token, not the URL — you end up sending the right token to the wrong server.
What is the expected results?
--env/-e passed to any airflowctl command should resolve the API URL and stored credentials for that environment, consistently with what auth login --env <x> stores. provide_api_client/get_client should read args.env (or AIRFLOW_CLI_ENVIRONMENT) and pass it through as Credentials(api_environment=...).
Anything else?
This is especially dangerous when a user manages multiple environments (staging/prod/etc.) via --env: commands appear to target the environment you specified, but silently execute against production instead. A dags trigger --env staging ... would actually trigger a DAG in production.
Related: #58230 added --api-token to non-auth commands for headless use but didn't wire --env/api_environment through the same path, which is effectively what left this gap open.
Are you willing to submit PR?
Code of Conduct
Airflow CTL Version
Latest stable version
Airflow CTL Command
airflowctl auth login --env staging --api-url --api-token ; airflowctl dags list --env staging
Keyring Backend / Version
macOS Keychain (
keyring.backends.macOS.Keyring) 25.7.0Auth Type
Token
What is the current behaviour?
--env/-eis accepted by every non-auth command (dags list,dags trigger,connections list, etc. — anything routed throughoutput_command_list/ARG_AUTH_ENVIRONMENT) and parses fine intoargs.env. But it's never actually used.provide_api_client's wrapper only forwardsapi_tokentoget_client():and
get_client()constructsCredentialswith noapi_environment:Credentials.__init__defaultsapi_environmentto"production". Since it's never overridden here, every non-auth command silently operates against theproductionenvironment — readingproduction.jsonfor the API URL and theapi_token_productionkeychain entry — no matter what--envyou pass.auth logindoesn't go through this path; it buildsCredentials(..., api_environment=args.env)directly, so login correctly targets the requested environment and stores the token under the right keychain key. The net effect:auth login --env stagingsucceeds and looks correct, but every subsequent command (dags list --env staging, etc.) silently talks toproductioninstead, using a stale/unrelated production token — surfacing as{'detail': 'Token Expired'}or{'detail': 'Invalid JWT token'}with no indication that the wrong environment was ever contacted.Reproduced: I confirmed by instrumenting
Credentials(client_kind=ClientKind.CLI, api_token=None).load()exactly asget_clientcalls it — withsys.argvset todags list --env staging, it resolvesapi_environment="production",api_urlto the production URL, andapi_tokento the production keychain entry.Workaround: setting
AIRFLOW_CLI_ENVIRONMENT=stagingin the environment (read directly viaos.getenvinCredentials.__init__, independent of the CLI arg) makes commands correctly target the intended environment.--api-tokenalone does not work around this, since it only overrides the token, not the URL — you end up sending the right token to the wrong server.What is the expected results?
--env/-epassed to anyairflowctlcommand should resolve the API URL and stored credentials for that environment, consistently with whatauth login --env <x>stores.provide_api_client/get_clientshould readargs.env(orAIRFLOW_CLI_ENVIRONMENT) and pass it through asCredentials(api_environment=...).Anything else?
This is especially dangerous when a user manages multiple environments (staging/prod/etc.) via
--env: commands appear to target the environment you specified, but silently execute against production instead. Adags trigger --env staging ...would actually trigger a DAG in production.Related: #58230 added
--api-tokento non-auth commands for headless use but didn't wire--env/api_environmentthrough the same path, which is effectively what left this gap open.Are you willing to submit PR?
Code of Conduct