Skip to main content

Authentication, config, and secrets

Preview
This feature is in preview and is subject to change.

A Flight reads three kinds of values at runtime: a MotherDuck access token that lets the Flight connect to your databases, a config map of non-secret key-value pairs surfaced as environment variables, and Flight secrets for sensitive values, also surfaced as environment variables. This page covers how each one works and what good defaults look like.

MotherDuck token

A Flight authenticates to MotherDuck with an access token that the runtime injects into the Flight's environment as MOTHERDUCK_TOKEN. By default there is nothing to configure: create the Flight without access_token_name and MotherDuck uses a default access token for your user (labeled MotherDuck Flights), so the Flight runs with your identity and permissions.

A Flight connects with:

import duckdb

def main():
con = duckdb.connect("md:") # picks up MOTHERDUCK_TOKEN automatically
con.execute("SELECT current_user(), current_database()").show()

if __name__ == "__main__":
main()

No credential handling in your code, no secrets in the source. The Flight inherits the token's identity and permissions: queries it runs show up against that user or service account, and the databases it can read or write are the ones that token can reach.

Running as a specific token

To run a Flight as a different identity, typically a service account, pass the name (label) of a token you've already created in MotherDuck instead of relying on the default. List the tokens available to you with:

List access token names
SELECT
  token_name
FROM
  md_access_tokens ();
SQL Editor loading...
Login to connect

Run that query in the MotherDuck UI SQL editor or DuckDB CLI. The docs SQL editor uses the browser MotherDuck runtime, which can lag newer MotherDuck table functions.

The token_name value is what you pass when creating or updating a Flight. The parameter is access_token_name in SQL (MD_CREATE_FLIGHT, MD_UPDATE_FLIGHT) and md_token_name in the MCP tools (create_flight, update_flight). See Authenticating to MotherDuck for how to create tokens.

Service accounts for production Flights

For Flights that run on a schedule, use a service account token rather than the default or a personal one. Personal tokens are tied to a user; if that user leaves or rotates their credentials, the Flight breaks. Service-account tokens are owned by the organization and survive personnel changes.

A good pattern:

  1. Create a service account for the workload (for example, flights-prod).
  2. Grant it the minimum set of database privileges the Flight needs.
  3. Create an access token for the service account and label it descriptively (flights-prod-ingest).
  4. Pass that token name to the Flight with access_token_name.

See Service accounts for the full setup.

Config: non-secret environment variables

The config field on a Flight is a map of string keys to string values. The runtime surfaces each entry as an environment variable.

Pass a region, a batch size, a feature flag, a destination table name — anything non-sensitive that you want to vary without editing the Python source:

import os
import duckdb

def main():
region = os.environ.get("REGION", "us-east-1")
batch_size = int(os.environ.get("BATCH_SIZE", "1000"))

con = duckdb.connect("md:")
con.execute("USE warehouse")
con.execute(f"INSERT INTO sales.metrics SELECT * FROM read_parquet('s3://incoming/{region}/*.parquet')")

if __name__ == "__main__":
main()

In SQL:

CALL MD_UPDATE_FLIGHT(
flight_id := '<flight_id>',
config := MAP {'REGION': 'eu-central-1', 'BATCH_SIZE': '5000'}
);

In the MCP update_flight tool, config is a JSON object.

Replace, not merge

Updating config replaces the entire map. If you have {"REGION": "us", "BATCH_SIZE": "1000"} and you call update_flight with config = {"REGION": "eu"}, the result is {"REGION": "eu"}BATCH_SIZE is gone. To change one entry, send the full map with the one change applied.

Override config per run

A run uses the Flight's stored config by default. To vary a value for a single run without editing the Flight, pass a config map to MD_RUN_FLIGHT:

CALL MD_RUN_FLIGHT(
flight_id := '<flight_id>',
config := MAP {'REGION': 'ap-south-1'}
);

You can only override keys that already exist in the Flight's stored config; a per-run override can't introduce a new key. Keys you don't override keep their stored values. The override applies to that run only and leaves the Flight's stored config and version untouched.

This means you don't need one Flight per configuration. Define the keys once, then point a single Flight at a different region, date partition, or destination table for an individual run. Each run records the config it used, so MD_LIST_FLIGHT_RUNS shows the exact values every run ran with:

SELECT run_number, status, config
FROM MD_LIST_FLIGHT_RUNS(flight_id := '<flight_id>')
ORDER BY run_number DESC;

In the MCP run_flight tool, pass config as a JSON object.

note

A Flight created before per-run config overrides shipped may report empty strings for its config values until you update the Flight once, which redeploys it.

Secrets: sensitive environment variables

For API keys, credentials for external services, and other sensitive values, use a Flight secret — a MotherDuck-stored secret of TYPE FLIGHTS that holds key-value pairs. Create one with CREATE SECRET:

CREATE SECRET my_api_secret IN MOTHERDUCK (
TYPE FLIGHTS,
PARAMS MAP {
'API_KEY': '<your_api_key>',
'ENDPOINT': 'https://api.example.com'
}
);

Attach it by name when creating or updating the Flight. Multiple secrets can be attached to a Flight:

CALL MD_UPDATE_FLIGHT(
flight_id := '<flight_id>',
flight_secret_names := ['my_api_secret', 'warehouse_secret']
);

At run time, each key in the secret's PARAMS map becomes an environment variable, alongside the config entries. The variable name joins the secret name and the key with an underscore — <secret_name>_<KEY> — and the case of each part is preserved exactly as you defined it. The name is not upper-cased. For a secret named my_api_secret with a key API_KEY, the variable is my_api_secret_API_KEY:

import os

def main():
api_key = os.environ["my_api_secret_API_KEY"]
endpoint = os.environ["my_api_secret_ENDPOINT"]

if __name__ == "__main__":
main()

Each key is also injected under its bare name, without the secret-name prefix, so API_KEY resolves on its own. Both names point to the same value:

import os

def main():
api_key = os.environ["API_KEY"] # bare name
same_key = os.environ["my_api_secret_API_KEY"] # namespaced name

if __name__ == "__main__":
main()

The bare name is added when you create or update a Flight, so a Flight created before this behavior shipped picks it up the next time you update it. Keys can collide in two ways: two attached secrets can define the same key, or a config entry and a secret key can share a name. In that case MotherDuck applies a fixed precedence: config wins over secrets, and among secrets the last one attached wins. Keep keys unique across a Flight's secrets and config, or read the namespaced <secret_name>_<KEY> form, which is always unambiguous.

warning

Secret variable names preserve case; they aren't normalized to upper case. A secret named decoy_github with a key TOKEN is injected as decoy_github_TOKEN, not DECOY_GITHUB_TOKEN. If you can't find an injected variable, print the matching keys at the start of main() to see their exact names:

import os

def main():
print(sorted(k for k in os.environ if "github" in k.lower()))

Unlike config, secret values aren't exposed in the Flight's metadata. Use secrets for anything sensitive and config for everything else. Like config, flight_secret_names is replaced on update, not merged.

In the MCP create_flight and update_flight tools, the parameter is md_secret_names.

Updating and dropping Flight secrets

After updating a Flight secret with CREATE OR REPLACE SECRET, update the Flight itself to redeploy it before scheduled runs pick up the new secret values:

CALL MD_UPDATE_FLIGHT(
flight_id := '<flight_id>',
flight_secret_names := ['my_api_secret', 'warehouse_secret']
);

If you drop a secret that's still attached to a Flight, the Flight fails until you recreate the secret or update the Flight to detach it.

Cloud storage credentials

You don't need a Flight secret for S3, GCS, or Azure access. Create a regular cloud storage secret in MotherDuck, and the Flight's DuckDB connection resolves it automatically:

import duckdb

def main():
con = duckdb.connect("md:")
# AWS S3 read using a secret stored in MotherDuck
con.execute("INSERT INTO raw.events SELECT * FROM read_parquet('s3://my-bucket/events/*.parquet')")

if __name__ == "__main__":
main()

The read_parquet call resolves the S3 credential through the MotherDuck secret store when a matching secret exists and is available to the user or service account behind the Flight token.

Avoid hard-coding credentials in config or in the Python source: anyone who can read the Flight can read those values.

Limits to know about

  • Per-run overrides can only set existing keys. A per-run config map passed to MD_RUN_FLIGHT overrides values for keys already defined on the Flight. It can't add a new key. To introduce a key, update the Flight's stored config.
  • Config is a flat string map. Nested structures need to be serialized (JSON-encoded into a single string value, for instance). Strings only; no numbers, booleans, or lists.
  • Secret variable names preserve case. A key in a secret named <secret> is injected as <secret>_<KEY>, with the case of both parts left exactly as defined. See Secrets: sensitive environment variables.