Skip to content

airflowctl ignores --env/AIRFLOW_CLI_ENVIRONMENT on non-auth commands — always uses production credentials #70519

Description

@AaronCoquet-Easypark

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?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions