Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ChristopherA/d39eaae5e62903b9e5f66edf5dd402bd to your computer and use it in GitHub Desktop.
Save ChristopherA/d39eaae5e62903b9e5f66edf5dd402bd to your computer and use it in GitHub Desktop.
Best Practices for Automating macOS `/usr/bin/security` App in Shell Scripts

Best Practices for Automating macOS /usr/bin/security App in Shell Scripts

WARNING: I've only dabbled with this in some scripts, and am not an expert. This document is based on my explorations, and has not been reviewed as actual best practices. USE AT YOUR OWN RISK!*

Securely managing macOS keychain items from the command line requires careful handling of access controls, error paths, and credential lifecycles. This guide collects recommended patterns and links to official documentation.

1. Principle of Least Privilege

  • Whitelist only necessary binaries
    When adding an item, use -T to restrict which executables can access it.
    security add-generic-password \
      -s "MyService" \
      -a "username" \
      -w "${SECRET}" \
      -T /usr/bin/security \
      -T /usr/local/bin/my-helper-script

This prevents “Any Application” from retrieving your secret. See “Using the keychain to manage user secrets” (https://developer.apple.com/documentation/security/using-the-keychain-to-manage-user-secrets)

  • Use ACLs for finer control For code-signed tools, set-key-partition-list lets you grant access by keychain-access groups rather than binary paths.

    security set-key-partition-list \
      -S apple-tool:,apple: \
      -s "MyService" \
      -k "${KEYCHAIN_PASS}" \
      /path/to/MyApp.keychain

2. Secure Storage & Retrieval

  • Avoid plaintext in scripts Don’t embed secrets in source. Add them interactively or via a CI/CD secret store:

    security add-generic-password -s "MyService" -a "username" -w
  • Use -w for clean output When retrieving, -w prints only the password, simplifying parsing:

    SECRET=$(security find-generic-password \
      -s "MyService" \
      -a "username" \
      -w)
  • Quote all expansions Prevent injection or word-splitting:

    security find-generic-password -s "$SERVICE" -a "$ACCOUNT" -w

3. Keychain Management

  • Dedicated “automation” keychain Keep script secrets out of the user’s login keychain:

    security create-keychain -p "${KC_PASS}" ci.keychain
    security set-keychain-settings -l 3600 ci.keychain     # auto-lock 1 hr
    security unlock-keychain -p "${KC_PASS}" ci.keychain
    security list-keychains -d user -s ci.keychain \
      ~/Library/Keychains/login.keychain-db

    When done, you can security lock-keychain ci.keychain or delete it.

  • Pin the search list Avoid fetching items from unintended keychains:

    security list-keychains -d user -s /path/to/ci.keychain
  • Clean up After your script finishes, lock or remove temporary keychains:

    security lock-keychain /path/to/ci.keychain
    security delete-keychain ci.keychain

4. Error Handling & Scripting Hygiene

  • Check exit codes Detect missing items, locked keychains, or ACL denials:

    if ! VAL=$(security find-generic-password -s "$SVC" -a "$ACC" -w); then
      echo "❌ Couldn’t fetch secret (exit $?)" >&2
      exit 1
    fi
  • Fail fast Use set -euo pipefail at the top of your script to catch undefined variables and errors early.

5. Headless & CI/CD Automation

  • Pre-authorize in non-GUI contexts macOS will block prompts under cron, SSH, or CI agents. Pre-grant /usr/bin/security (and any custom helper) with -T or via Keychain Access → Access Control to avoid “User interaction is not allowed.”

  • Use environment-isolated keychains In CI pipelines, spin up an ephemeral keychain just for that run, then destroy it, ensuring no leftover secrets.

6. Production & Long-Term Considerations

  • Prefer native APIs for apps Shell scripts are convenient but apps should integrate via Keychain Services (Objective-C/Swift) or CryptoKit with proper entitlements and keychain-access-groups. See the Security framework reference (https://developer.apple.com/documentation/security)

  • Threat model your use case Understand who might target your automation, and choose expiration, ACLs, and encryption strategies accordingly. A simple script may suffice for personal use; high-security environments may demand hardware tokens (e.g., Secure Enclave) or HSM solutions.

7. Additional Resources

Keep scripts minimal, tightly scoped, and always treat your keychain items as first-class security assets.


Some old Zsh code that worked in 2022, but hasn't been tested or security reviewed:

# DESC: Retrieves secret from keychain
# ARGS: $2 (REQUIRED) secret name to be retrieved from keychain
# OUTS: None
function _getsecret() {

  local Function_Name Secret_Name Secret_Value

  Function_Name=$(echo ${FUNCNAME[0]} | cut -c 2-)

  #printf "\n\tparams: 0: ${0-} 1: ${1-} 2: ${2-} 3: ${3-}\n"
  if [ $Function_Name == "${1-}" ]; then #being called from _main
    #printf "\tcalled from _main\n"
    shift
  else # function being called internally
    #printf "\tcalled internally\n"
    Function_Name="getsecret"
  fi

  if [ -z "${1-}" ] ; then
      script_exit "ERROR: '$script_name $Function_Name' requires a <secret.name> to search. See '$script_name $Function_Name --help'."
  fi

  if [[ "${1:-}" =~ ^-h$|^--help$ ]]
  then
    cat <<EOF
Name: getsecret from login keychain
Description:
  Searches for retrieves the named secret from the current user's
  MacOS login keychain.
Usage:
  $script_name $Function_Name <secret.name>
  $script_name $Function_Name -h | --help
Options:
  -h --help  Display this usage information.
Example:
  profile getsecret christophera.github.token
EOF
    return 0
  fi

  Current_User=$( /usr/bin/stat -f "%Su" /dev/console )
  Secret_Value="$(security find-generic-password -a $Current_User -s "$1" -w $LOCAL_KEYCHAIN)"
  ## TBD: We need to do some better error handling here. For instance, if result is
  ## "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain."
  ## then we need to return this error so the function calling check and do the correct thing.
  ## in particular, in `profile check` the `Desired_Host_Name=` will be empty on first use as
  ## "$Device_Serial_Number.hostname" has not been set yet.
  echo $Secret_Value
}

# DESC: Removes secret from keychain
# ARGS: $2 (REQUIRED) secret name on keychain to delete
# OUTS: None
function _rmsecret() {

  local Function_Name Secret_Name Secret_Value

  Function_Name=$(echo ${FUNCNAME[0]} | cut -c 2-)

  if [ $Function_Name == "${1-}" ]; then #being called from _main
    shift
  else # function being called internally
    Function_Name="rmsecret"
  fi

  if [ -z "${1-}" ] ; then
      script_exit "ERROR: '$script_name $Function_Name' requires a <secret.name> to delete. See '$script_name $Function_Name --help'."
  fi

  if [[ "${1:-}" =~ ^-h$|^--help$ ]]
  then
    cat <<EOF
Name: rmsecret from login keychain
Description:
  Searches for retrieves the named secret from the current user's
  MacOS login keychain.
Usage:
  $script_name $Function_Name <secret.name>
  $script_name $Function_Name -h | --help
Options:
  -h --help  Display this usage information.
Example:
  profile getsecret christophera.github.token
EOF
    return 0
  fi

  Current_User=$( /usr/bin/stat -f "%Su" /dev/console )
  Result="$(security delete-generic-password -a $Current_User -s "$1" $LOCAL_KEYCHAIN)"
  #echo $Result
}

# DESC: Sets secret to local keychain
# ARGS: $2 (REQUIRED) secret name to be stored in local keychain
# ARGS: $3 (REQUIRED) value for secret to be stored in local keychain
# OUTS: None
function _setsecret() {

  local Function_Name Secret_Name Secret_Value Result

  Function_Name=$(echo ${FUNCNAME[0]} | cut -c 2-)

  #printf "\n\tparams: 0: ${0-} 1: ${1-} 2: ${2-} 3: ${3-}\n"
  if [ $Function_Name == "${1-}" ]; then #being called from _main
    #printf "\tcalled from _main\n"
    shift
  else # function being called internally
    #printf "\tcalled internally\n"
    Function_Name="setsecret"
  fi

  if [[ -z "${0-}" || -z "${1-}" || -z "${2-}" ]] ; then
      script_exit "ERROR: '$script_name $Function_Name' requires a <secret.name> & <value> to set. See '$script_name $Function_Name --help'."
  fi

  if [[ "${1:-}" =~ ^-h$|^--help$ ]]
  then
    cat <<EOF
Name: setsecret from login keychain
Description:
  Sets the named secret in the current user's MacOS login keychain
  to the value.
Usage:
  $script_name $Function_Name <secret.name> <value>
  $script_name $Function_Name -h | --help
Options:
  -h --help  Display this usage information.
Example:
  profile set secret christophera.git.email [email protected]
EOF
    return 0
  fi
  Current_User=$( /usr/bin/stat -f "%Su" /dev/console )
  Result="$(security add-generic-password -D secret -U -a $Current_User -s "$1" -w "$2" $LOCAL_KEYCHAIN)"
  #echo $Result
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment