When you’re managing a small internal beta program, adding and removing TestFlight testers through the App Store Connect web UI is tedious but survivable. When you’re managing multiple apps, multiple testing groups, bulk CSV imports, and a team of people who need to do it without an Apple Developer account, the web UI stops being viable.
The App Store Connect API has been around since 2018 and covers the full TestFlight management lifecycle. What it doesn’t have is a simple CLI interface for the things you actually need to do day-to-day. So I built one.
What It Does
The TestFlight Management Script handles the full tester lifecycle via the App Store Connect API:
- Add testers to Internal or External TestFlight groups
- Remove testers from all groups
- Bulk import testers from CSV
- List groups and current testers
- Track and resend pending invitations
- Invite users to App Store Connect and manage their roles (added in v1.6)
It’s available in two interfaces: an interactive CLI (testflight.sh) and a native macOS GUI built with SwiftDialog (testflight-gui.sh). Both share a common library (testflight-common.sh) that contains all the API logic, so behavior is identical — only the UI layer differs.
The Credential Problem
App Store Connect API authentication uses JWT signed with an ES256 private key — a .p8 file you download once from App Store Connect and can’t regenerate. Losing it means creating a new API key and updating everything that uses the old one.
The obvious approach is to store the .p8 file on disk. That’s how the Apple documentation shows it. It’s also a credential that now lives on your laptop, probably outside your git repo but not in any managed secret store, backed up wherever your filesystem backs up to.
The approach I wanted: everything in 1Password, nothing on disk.
Storing the Private Key as Content
1Password supports storing arbitrary text in custom fields. A .p8 file is plain text — an EC private key in PEM format:
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
-----END PRIVATE KEY-----
The entire contents go into a private_key field in the App Store Connect API 1Password item. When the script loads, it retrieves the key content and writes it to a temp file:
tmp_key=$(mktemp /tmp/testflight_key.XXXXXX.p8)
chmod 600 "$tmp_key"
echo "$private_key_content" > "$tmp_key"
trap "rm -f '$tmp_key'" EXIT INT TERM
The temp file exists only for the duration of the script run. The trap ensures it’s cleaned up even if the script crashes. This achieves zero-permanent-secrets-on-disk: the key lives in 1Password, appears briefly as a temp file when needed, and disappears.
A Single 1Password Call
The script makes one op item get call to load all configuration:
op_json=$(op item get "App Store Connect API" --vault "IT Internal" --format json)
api_key_id=$(echo "$op_json" | jq -r '.fields[] | select(.label=="api_key_id") | .value')
issuer_id=$(echo "$op_json" | jq -r '.fields[] | select(.label=="issuer_id") | .value')
private_key=$(echo "$op_json" | jq -r '.fields[] | select(.label=="private_key") | .value')
apps=$(echo "$op_json" | jq -r '.fields[] | select(.label=="apps") | .value')
# ... slack_notification_url, slack_notifications_enabled, approved_email_domains
This matters for two reasons. First, macOS TCC prompts appear per-process-per-request with some 1Password configurations — a single call means one prompt instead of six. Second, the entire configuration — API key, apps list, Slack webhook, approved email domains — is in one place. Adding a new app means updating one 1Password field, not editing a config file.
Credential loading priority:
- 1Password CLI (if
opis installed and authenticated) - Config file at
~/.config/testflight/appstoreauth.json - Environment variables
The fallback chain means the script works in environments where 1Password isn’t available — CI/CD, other team members’ setups — without requiring the 1Password CLI.
JWT Authentication from Scratch
App Store Connect uses JWT signed with ES256 (ECDSA over P-256 with SHA-256). Implementing this in bash without external dependencies was the most technically interesting part of the project.
Header:
{"alg":"ES256","kid":"<api_key_id>","typ":"JWT"}
Payload:
{"iss":"<issuer_id>","exp":<now + 1200>,"aud":"appstoreconnect-v1"}
Both are base64URL encoded (standard base64 with +/ replaced by -_ and padding stripped).
Signing is the tricky part. OpenSSL produces DER-encoded ECDSA signatures, but JWT requires the signature in a specific format: the R and S components concatenated, each padded to 32 bytes, then base64URL encoded. OpenSSL’s asn1parse can extract the R and S values from the DER output:
convert_ec_signature() {
local der_sig="$1"
# Parse ASN.1 structure
r=$(echo "$der_sig" | openssl asn1parse -inform der | grep "INTEGER" | head -1 | awk -F: '{print $NF}')
s=$(echo "$der_sig" | openssl asn1parse -inform der | grep "INTEGER" | tail -1 | awk -F: '{print $NF}')
# Pad to 64 hex chars (32 bytes)
r=$(printf "%064s" "$r" | tr ' ' '0')
s=$(printf "%064s" "$s" | tr ' ' '0')
# Concatenate and base64URL encode
echo "${r}${s}" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='
}
The resulting JWT is valid for 20 minutes — Apple’s requirement. Since each script invocation generates a fresh token, there’s no refresh logic to manage.
Version Evolution
The project launched in November 2025 with basic add/remove functionality and grew significantly:
v1.0 (Nov 2025) — Core add/remove testers, basic invitation flow
v1.1 (Nov 2025) — Resend invitations to pending testers
v1.3 (Feb 2026) — First 1Password integration, with fallback to config file. The key was still a file path reference at this point.
v1.3.1 (Feb 2026) — Biggest security improvement: private key content stored in 1Password (not just the path), with automatic temp file creation and cleanup. The “zero secrets on disk” model.
v1.4–1.5 (Feb 2026) — Apps configuration and Slack webhook moved to 1Password. At this point the config file became a legacy fallback rather than the primary mechanism.
v1.6 (Feb 2026) — App Store Connect team management: invite users with role selection, update existing roles, email domain restrictions, app access restrictions. The script now manages the full team lifecycle, not just TestFlight.
v1.7 (Mar 2026) — UX streamlining and SwiftDialog GUI. Auto-selection of apps/groups when only one exists, gated menus, combined name fields, --dry-run CLI flag, cancel from any prompt.
The 1Password migration (v1.3 → v1.5) was the most consequential architectural change. Moving credentials progressively from “file on disk” → “1Password with file path reference” → “1Password with key content” eventually got to a state where a fresh machine with only the 1Password CLI installed can run the script with no other setup. That’s the right target for shared team tooling.
The SwiftDialog GUI
The CLI is the primary interface, but there are team members who aren’t comfortable with interactive terminal menus. SwiftDialog makes it practical to offer a native macOS app for the same functionality.
The GUI (testflight-gui.sh) is a thin wrapper around testflight-common.sh. Every API call goes through the same library functions — the GUI handles only presentation. That means changes to API logic, error handling, or credential loading happen once and apply to both interfaces.
Key UI patterns used:
Loading dialog — shown immediately while 1Password authentication happens, before any user interaction. This prevents the 5-10 second delay from appearing as a hang.
Auto-selection — when only one app or group is configured, the picker is skipped. This was the single biggest UX improvement for our setup, where most users work with one app.
Mini notifications — auto-dismissing popups (5-second timer) for success/error results. Avoids requiring an explicit click to dismiss every action confirmation.
Gated menu — the main action menu only shows options after an app is selected. Previously, selecting “Add Tester” before choosing an app produced a confusing “No app selected” error. The gate makes the required state explicit.
Dry Run Mode
The script supports full dry-run operation: all API calls are mocked, output shows what would have been created/modified, no actual changes are made. This is useful for testing CSV bulk imports before committing.
./testflight.sh --dry-run
The dry-run banner appears in the menu header when active so there’s no ambiguity about what mode you’re in.
What I’d Do Differently
The shared library approach (testflight-common.sh) was the right call from the start, but it’s grown to 600+ lines and deserves a refactor into more focused modules. Authentication, API calls, and output formatting are interleaved in ways that make individual pieces harder to test in isolation.
The JWT implementation is functional but verbose. In retrospect, a small Python script for token generation would have been cleaner than the bash/OpenSSL/asn1parse pipeline — Python’s cryptography library handles ES256 JWT in about eight lines. The bash approach was a fun exercise but adds maintenance surface that isn’t worth the “no dependencies” benefit.
The 1Password single-call approach is good for interactive use but requires rethinking for automated contexts. When running from a CI environment or LaunchDaemon, the op session handling needs to be set up separately. The fallback to config file handles this, but the credential loading code is more complex than I’d like.