Skip to Content
CLI & DevOpsDevOps Guide

DevOps Guide

Automate certificate pin management in production environments using the TrustPin CLI. This guide covers the complete workflow for managing certificate pins from installation to automated CI/CD deployments.

Overview

TrustPin uses a simple process for certificate pin updates:

  1. Stage the pin changes in your project configuration
  2. Sign the configuration and publish it to the CDN

There are two ways to stage, and for most pipelines the first is the one you want:

ApproachCommandWhen
Automatic (recommended)projects refresh-certsThe certificate is live or visible in Certificate Transparency. The CLI discovers the pins and applies them.
Manualprojects cleanup + projects upsertYou need a specific digest type, a custom expiry, or you’re pinning a certificate TrustPin can’t observe.

Both approaches update the stored configuration only. Nothing is live until projects sign succeeds.

This separation allows you to:

  • Stage multiple pin changes before publishing
  • Review configuration changes before they go live
  • Maintain an audit trail of all pin modifications
  • Use different security controls for staging vs. publishing

Quick Start

1. Install the CLI

Homebrew (Recommended):

brew tap trustpin-cloud/trustpin-cli brew trust trustpin-cloud/trustpin-cli # one-time approval required by Homebrew 6.0+ brew install trustpin-cli

Direct Download (Linux x64 for CI/CD):

curl -L https://github.com/trustpin-cloud/homebrew-trustpin/releases/latest/download/trustpin-cli-linux-x64 -o trustpin-cli chmod +x trustpin-cli sudo mv trustpin-cli /usr/local/bin/

See Installation for platform-specific instructions.

2. Configure Authentication

Set the TRUSTPIN_API_TOKEN environment variable:

export TRUSTPIN_API_TOKEN="tp_your_token_here"

Get your Personal Access Token from TrustPin Console .

The CLI automatically uses this environment variable when available.

3. Get Your Project IDs

# List all projects trustpin-cli projects list # Get IDs in JSON format trustpin-cli projects list --output json | jq -r '.data.projects[] | "\(.organization_id) \(.id) \(.name)"'

Note your Organization ID and Project ID for subsequent commands.


Certificate Pin Management Workflow

If the certificate you want to pin is already live, or has been issued and logged to Certificate Transparency, you don’t need to extract anything by hand. projects refresh-certs performs the lookup and applies the pins for you.

ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" # 1. Refresh the domain's pins from its current certificates, # dropping this domain's already-expired pins in the same pass trustpin-cli projects refresh-certs "$ORG_ID" "$PROJECT_ID" \ --domain api.example.com \ --remove-expired \ --output json # 2. Publish trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" --password "$MASTER_PASSWORD" # 3. Confirm what's live trustpin-cli projects jws "$ORG_ID" "$PROJECT_ID" --verify

Why this is the default recommendation:

  • No digest handling. No openssl pipeline, no base64 juggling, no chance of pinning the certificate hash when you meant the SPKI hash.
  • Backup pins come for free. The lookup returns every unexpired issuance TrustPin can see, so a newly issued certificate gets pinned alongside the one still being served. That is exactly the dual-pin overlap that makes rotation safe.
  • Safe to run unattended. Existing pins are never discarded, a failed lookup writes nothing at all, and an unchanged run doesn’t bump the configuration version. That means you can run it on a schedule without babysitting it.

Refreshing every domain in a project:

#!/bin/bash set -euo pipefail ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" # Pull the domain list straight out of the stored configuration DOMAINS=$(trustpin-cli projects config "$ORG_ID" "$PROJECT_ID" | \ jq -r '.data.config.domains[].domain') for DOMAIN in $DOMAINS; do echo "Refreshing $DOMAIN" trustpin-cli projects refresh-certs "$ORG_ID" "$PROJECT_ID" \ --domain "$DOMAIN" --remove-expired --output json | jq -c '.status' done # Sign once, after every domain is up to date trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" --password "$MASTER_PASSWORD" trustpin-cli projects jws "$ORG_ID" "$PROJECT_ID" --verify

Scheduled refresh (GitHub Actions):

Because refresh-certs is idempotent and fails closed, it makes a good scheduled job. This workflow refreshes a domain daily and only signs when the configuration actually changed:

name: Refresh certificate pins on: schedule: - cron: "0 5 * * *" # daily 05:00 UTC workflow_dispatch: jobs: refresh: runs-on: ubuntu-latest env: TRUSTPIN_API_TOKEN: ${{ secrets.TRUSTPIN_API_TOKEN }} ORG_ID: ${{ vars.TRUSTPIN_ORG_ID }} PROJECT_ID: ${{ vars.TRUSTPIN_PROJECT_ID }} steps: - name: Install TrustPin CLI run: | eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" brew tap trustpin-cloud/trustpin-cli brew trust trustpin-cloud/trustpin-cli brew install trustpin-cli - name: Record configuration version before refresh id: before run: | VERSION=$(trustpin-cli projects get "$ORG_ID" "$PROJECT_ID" --output json | \ jq -r '.data.project.config_version') echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Refresh pins run: | trustpin-cli projects refresh-certs "$ORG_ID" "$PROJECT_ID" \ --domain api.example.com \ --remove-expired \ --output json - name: Sign and publish if anything changed env: MASTER_PASSWORD: ${{ secrets.TRUSTPIN_MASTER_PASSWORD }} run: | AFTER=$(trustpin-cli projects get "$ORG_ID" "$PROJECT_ID" --output json | \ jq -r '.data.project.config_version') if [ "$AFTER" = "${{ steps.before.outputs.version }}" ]; then echo "No pin changes, nothing to publish." exit 0 fi trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" --password "$MASTER_PASSWORD" trustpin-cli projects jws "$ORG_ID" "$PROJECT_ID" --verify

Note: refresh-certs only refreshes pins for domains you name. It never adds domains you didn’t ask for, and it never removes a domain from the project.


Manual Workflow: Extract, Upsert, Publish

Use the manual workflow when refresh-certs can’t help: you’re pinning a certificate TrustPin can’t observe (an internal-only host that isn’t in Certificate Transparency), you need a specific digest type other than SPKI SHA-256, or you need an expiry that differs from the certificate’s own.

Step 1: Extract Pin from Certificate

Extract the SPKI SHA-256 fingerprint and expiration date from your certificate:

#!/bin/bash CERT_FILE="cert.pem" # Extract SPKI SHA-256 (OWASP recommended - pins the public key) SPKI_SHA256=$(openssl x509 -in "$CERT_FILE" -pubkey -noout | \ openssl pkey -pubin -outform der | \ openssl dgst -sha256 -binary | \ base64) # Extract expiration date (portable across Linux/macOS) EXPIRES_EPOCH=$(openssl x509 -in "$CERT_FILE" -noout -enddate | \ cut -d= -f2 | \ xargs -I{} date -jf "%b %d %H:%M:%S %Y %Z" "{}" +%s 2>/dev/null || \ date -d "$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)" +%s) EXPIRES=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ) echo "SPKI SHA-256: $SPKI_SHA256" echo "Expires: $EXPIRES"

Example output:

SPKI SHA-256: oxVSdCLgthL3M5Vnnzepq8WWlkUfRPYkpjLpm+wn+1o= Expires: 2026-04-13T08:37:02Z

Tip: You can skip the manual openssl pipeline entirely and let the CLI discover pins for you with domains certificates.

Before adding the new pin, drop any pins that have already expired. This keeps the stored configuration free of dead pins so you only ever sign live ones. Cleanup updates the stored configuration only. It doesn’t publish, so it’s safe to run ahead of the upsert/sign steps below.

# Preview first (optional) trustpin-cli projects cleanup <organization-id> <project-id> --dry-run # Remove expired pins trustpin-cli projects cleanup <organization-id> <project-id>

Cleanup is idempotent. If there’s nothing expired, it’s a no-op and the configuration version is left unchanged.

Step 3: Upsert the Pin

Add or update the certificate pin in your project configuration:

trustpin-cli projects upsert \ <organization-id> \ <project-id> \ --domain api.example.com \ --pin spki-sha256:$SPKI_SHA256 \ --expires $EXPIRES \ --output json

Example output:

{ "status": "success", "operation": "upsert", "domain": "api.example.com", "action": "added", "pin": { "type": "spki-sha256", "value": "oxVSdCLgthL3M5Vnnzepq8WWlkUfRPYkpjLpm+wn+1o=", "expires_at": "2026-04-13T08:37:02Z" }, "config_version": { "before": 2, "after": 3 } }

Note: The configuration version increments, but changes are not yet published.

Step 4: Sign and Publish

Sign the configuration and publish it to the CDN:

Cloud-Managed Keys:

trustpin-cli projects sign \ <organization-id> \ <project-id> \ --password <master-password>

Bring Your Own Key (BYOK):

trustpin-cli projects sign \ <organization-id> \ <project-id> \ --private-key path/to/private-key.pem

Example output:

[1/5] Getting project information Project: Production API (Managed CDN with Cloud Keys) [2/5] Loading project configuration Configuration loaded with 3 domains [3/5] Preparing private key Using cloud-managed private key [4/5] Creating and signing JWT JWT signed successfully [5/5] Uploading signed configuration Configuration published successfully!

The signed configuration is now live and available to your mobile apps.

Rehearse the signature first (--dry-run)

In a pipeline, the expensive failure is discovering a bad key or password after you’ve staged configuration changes you can no longer publish. --dry-run runs the entire signing path: it loads the configuration, decrypts the key, validates it against the project’s public key, signs, and verifies the signature. It then stops before uploading:

trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" \ --private-key "$PRIVATE_KEY_FILE" \ --dry-run
[1/4] Getting project information [2/4] Loading project configuration [3/4] Preparing private key Key pair validation successful [4/4] Creating and signing JWT JWS signature verified successfully Dry run: signature verified locally, nothing was uploaded Dry run successful - nothing was published

A mismatched key still fails with INVALID_KEY_PAIR and a wrong password still fails with INCORRECT_PASSWORD, but nothing about the project changes. Put this early in the job, right after secrets are wired up, as a credentials preflight:

# Preflight: fail fast if the signing credentials are wrong if ! trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" \ --private-key "$PRIVATE_KEY_FILE" --dry-run > /dev/null; then echo "❌ Signing credentials are invalid for this project, aborting before staging changes" exit 1 fi

Both --dry-run and --verbose also print the signed JWS (as data.jws with --output json), so you can archive or independently verify the exact token that would be published.

See Rehearsing with --dry-run for full details.


Complete Workflow Script

The refresh-based script is short enough to read at a glance, and every step fails closed:

#!/bin/bash set -euo pipefail ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" DOMAIN="api.example.com" echo "═══ Step 1: Preflight (verify signing credentials) ═══" trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" \ --password "$MASTER_PASSWORD" --dry-run > /dev/null echo "✅ Signing credentials valid" echo "" echo "═══ Step 2: Record current configuration version ═══" BEFORE=$(trustpin-cli projects get "$ORG_ID" "$PROJECT_ID" --output json | \ jq -r '.data.project.config_version') echo " Configuration version: $BEFORE" echo "" echo "═══ Step 3: Refresh pins from current certificates ═══" trustpin-cli projects refresh-certs "$ORG_ID" "$PROJECT_ID" \ --domain "$DOMAIN" \ --remove-expired \ --output json | jq '.' AFTER=$(trustpin-cli projects get "$ORG_ID" "$PROJECT_ID" --output json | \ jq -r '.data.project.config_version') if [ "$BEFORE" = "$AFTER" ]; then echo "" echo "✅ Pins already up to date, nothing to publish" exit 0 fi echo "" echo "═══ Step 4: Sign and Publish ═══" trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" --password "$MASTER_PASSWORD" echo "" echo "═══ Step 5: Verify what's live ═══" trustpin-cli projects jws "$ORG_ID" "$PROJECT_ID" --verify echo "" echo "✅ Certificate pins refreshed and published (v$BEFORE → v$AFTER)!"

Using upsert (manual extraction)

This script automates the entire manual process with error handling:

#!/bin/bash set -e ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" DOMAIN="api.example.com" CERT_FILE="certs/api-cert.pem" echo "═══ Step 1: Extract Pin from Certificate ═══" SPKI_SHA256=$(openssl x509 -in "$CERT_FILE" -pubkey -noout | \ openssl pkey -pubin -outform der | \ openssl dgst -sha256 -binary | \ base64) # Extract expiration date (portable across Linux/macOS) EXPIRES_EPOCH=$(openssl x509 -in "$CERT_FILE" -noout -enddate | \ cut -d= -f2 | \ xargs -I{} date -jf "%b %d %H:%M:%S %Y %Z" "{}" +%s 2>/dev/null || \ date -d "$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)" +%s) EXPIRES=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ) echo " SPKI SHA-256: $SPKI_SHA256" echo " Expires: $EXPIRES" echo "" echo "═══ Step 2: Clean Up Expired Pins ═══" trustpin-cli projects cleanup "$ORG_ID" "$PROJECT_ID" echo "" echo "═══ Step 3: Upsert Certificate Pin ═══" RESULT=$(trustpin-cli projects upsert "$ORG_ID" "$PROJECT_ID" \ --domain "$DOMAIN" \ --pin "spki-sha256:$SPKI_SHA256" \ --expires "$EXPIRES" \ --output json) echo "$RESULT" | jq '.' # Verify success STATUS=$(echo "$RESULT" | jq -r '.status') if [ "$STATUS" != "success" ]; then echo "❌ Failed to upsert pin" exit 1 fi ACTION=$(echo "$RESULT" | jq -r '.action') echo "✅ Pin ${ACTION} successfully" echo "" echo "═══ Step 4: Sign and Publish Configuration ═══" trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" \ --password "$MASTER_PASSWORD" echo "" echo "✅ Certificate pin updated and published!"

Advanced Use Cases

Verifying Published Configuration

After signing and publishing, verify the configuration is correctly deployed to the CDN:

# Verify JWS signature using public key from CDN trustpin-cli projects jws $ORG_ID $PROJECT_ID --verify # Decode and inspect published configuration trustpin-cli projects jws $ORG_ID $PROJECT_ID --decode # Save published JWS for archival/compliance trustpin-cli projects jws $ORG_ID $PROJECT_ID \ --output-file "archive/config-$(date +%Y%m%d-%H%M%S).jws"

Why validation matters:

  • Confirms the sign command successfully published to CDN
  • Catches CDN propagation delays or failures
  • Provides audit trail of published configurations
  • Enables comparison between database and CDN state

Example validation script:

#!/bin/bash set -e ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" echo "Verifying published configuration..." # Check if JWS is published JWS=$(trustpin-cli projects jws $ORG_ID $PROJECT_ID 2>/dev/null) if [ -z "$JWS" ]; then echo "❌ No JWS published on CDN" exit 1 fi # Verify signature if ! trustpin-cli projects jws $ORG_ID $PROJECT_ID --verify &>/dev/null; then echo "❌ JWS signature verification failed" exit 5 fi echo "✅ JWS signature valid" # Check version alignment trustpin-cli projects jws $ORG_ID $PROJECT_ID --decode | grep -E "(Current DB Version|Published Version)" echo "✅ Published configuration validated"

Self-Hosted CDN Deployment

For organizations hosting JWS configurations on their own infrastructure:

Workflow:

  1. Use TrustPin to manage and sign configurations (upsert + sign)
  2. Download the signed JWS from TrustPin CDN
  3. Deploy to your own CDN/servers
  4. Configure mobile SDKs to fetch from your infrastructure

Deployment script:

#!/bin/bash set -e ORG_ID="fba3418e-b5ae-b273-4bab-6da6ae07ba99" PROJECT_ID="9caaea0a-80ea-013e-4e7b-cee6bfb52b36" CDN_PATH="/var/www/cdn/trustpin" echo "═══ Step 1: Upsert Certificate Pins ═══" # Update your pins... trustpin-cli projects upsert $ORG_ID $PROJECT_ID \ --domain api.example.com \ --pin spki-sha256:$SPKI_SHA256 \ --expires $EXPIRES echo "" echo "═══ Step 2: Sign Configuration ═══" trustpin-cli projects sign $ORG_ID $PROJECT_ID \ --password "$MASTER_PASSWORD" echo "" echo "═══ Step 3: Download Signed JWS ═══" trustpin-cli projects jws $ORG_ID $PROJECT_ID \ --output-file "$CDN_PATH/config.jws" echo "" echo "═══ Step 4: Verify JWS Integrity ═══" if ! trustpin-cli projects jws $ORG_ID $PROJECT_ID --verify; then echo "❌ JWS verification failed - aborting deployment" exit 5 fi echo "" echo "═══ Step 5: Deploy to CDN ═══" # Example: Upload to S3, CloudFront, or your CDN aws s3 cp "$CDN_PATH/config.jws" s3://your-bucket/trustpin/config.jws \ --cache-control "public, max-age=3600" \ --metadata "version=$(date +%s)" # Invalidate CDN cache if needed aws cloudfront create-invalidation \ --distribution-id YOUR_DISTRIBUTION_ID \ --paths "/trustpin/config.jws" echo "✅ JWS deployed to self-hosted CDN"

Benefits:

  • Full control over CDN infrastructure and caching
  • Compliance with data residency requirements
  • Integration with existing CDN/monitoring systems
  • Custom cache invalidation strategies

AWS ACM: Automated Certificate Pinning on Renewal

When AWS Certificate Manager (ACM) automatically renews a certificate, this workflow detects the renewal via EventBridge, extracts the new certificate’s pins, upserts them into TrustPin, and signs/publishes the configuration using a BYOK private key stored in AWS Secrets Manager.

Simpler alternative: If the renewed certificate is served publicly, projects refresh-certs replaces steps 3 to 6 below. The ACM export, the openssl extraction, the date conversion, and the separate cleanup call all collapse into one command:

trustpin-cli projects refresh-certs "$ORG_ID" "$PROJECT_ID" \ --domain "$DOMAIN" --remove-expired trustpin-cli projects sign "$ORG_ID" "$PROJECT_ID" --private-key "$PRIVATE_KEY_FILE"

The Lambda then needs no acm:GetCertificate permission and no openssl in the bundle. EventBridge just tells it when to refresh. Keep the full extraction flow below when the certificate is served on a private endpoint TrustPin can’t reach, or when you need to pin it before it goes live.

Architecture

ACM Certificate Renewal EventBridge Rule ──▶ Lambda Function (ACM Action event) │ ├── 1. Get certificate from ACM (aws sdk) ├── 2. Extract SPKI pin (openssl) ├── 3. Clean up expired pins (trustpin-cli projects cleanup) ├── 4. Upsert pin (trustpin-cli projects upsert) ├── 5. Fetch BYOK key from Secrets Manager └── 6. Sign & publish (trustpin-cli projects sign --private-key)

Prerequisites:

  • ACM certificate with automatic renewal enabled
  • TrustPin project configured with BYOK (Bring Your Own Key)
  • BYOK private key stored in AWS Secrets Manager
  • TrustPin API token stored in AWS Secrets Manager

SAM Template (template.yaml)

AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > Automated TrustPin certificate pin updates on ACM certificate renewal. Detects ACM renewals via EventBridge, extracts pins, and publishes signed configuration using BYOK. Parameters: TrustPinOrgId: Type: String Description: TrustPin organization ID TrustPinProjectId: Type: String Description: TrustPin project ID AcmCertificateArn: Type: String Description: ARN of the ACM certificate to monitor Domain: Type: String Description: Domain name associated with the certificate (e.g., api.example.com) TrustPinApiTokenSecretArn: Type: String Description: ARN of the Secrets Manager secret containing the TrustPin API token ByokPrivateKeySecretArn: Type: String Description: ARN of the Secrets Manager secret containing the BYOK private key (PEM) Resources: # EventBridge rule: triggers on ACM certificate renewal AcmRenewalRule: Type: AWS::Events::Rule Properties: Description: Triggers on ACM certificate renewal completion EventPattern: source: - aws.acm detail-type: - "ACM Certificate Action" detail: ActionType: - RENEWAL CertificateArn: - !Ref AcmCertificateArn State: ENABLED Targets: - Id: TrustPinPinUpdater Arn: !GetAtt PinUpdaterFunction.Arn # Permission for EventBridge to invoke the Lambda AcmRenewalPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref PinUpdaterFunction Action: lambda:InvokeFunction Principal: events.amazonaws.com SourceArn: !GetAtt AcmRenewalRule.Arn # Lambda function: updates TrustPin pins on renewal PinUpdaterFunction: Type: AWS::Serverless::Function Properties: FunctionName: trustpin-acm-pin-updater Runtime: provided.al2023 Handler: bootstrap Timeout: 120 MemorySize: 256 Architectures: - x86_64 Environment: Variables: TRUSTPIN_ORG_ID: !Ref TrustPinOrgId TRUSTPIN_PROJECT_ID: !Ref TrustPinProjectId DOMAIN: !Ref Domain ACM_CERTIFICATE_ARN: !Ref AcmCertificateArn API_TOKEN_SECRET_ARN: !Ref TrustPinApiTokenSecretArn BYOK_KEY_SECRET_ARN: !Ref ByokPrivateKeySecretArn Policies: - Version: '2012-10-17' Statement: # Read the ACM certificate - Effect: Allow Action: - acm:GetCertificate - acm:DescribeCertificate Resource: !Ref AcmCertificateArn # Read secrets (API token + BYOK private key) - Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - !Ref TrustPinApiTokenSecretArn - !Ref ByokPrivateKeySecretArn

Lambda Handler Script (handler.sh)

The Lambda uses a custom runtime (provided.al2023) to run a shell script with the TrustPin CLI binary bundled in the deployment package.

#!/bin/bash set -euo pipefail # --- Custom Lambda Runtime Loop --- # Polls the Lambda Runtime API for invocation events and processes them. RUNTIME_API="http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime" while true; do # Get next event HEADERS=$(mktemp) EVENT=$(curl -sS -D "$HEADERS" "${RUNTIME_API}/invocation/next") REQUEST_ID=$(grep -i "Lambda-Runtime-Aws-Request-Id" "$HEADERS" | tr -d '\r' | cut -d' ' -f2) # Process the event RESPONSE=$(process_event "$EVENT" 2>&1) && STATUS="success" || STATUS="error" if [ "$STATUS" = "success" ]; then curl -sS -X POST "${RUNTIME_API}/invocation/${REQUEST_ID}/response" \ -d "{\"status\": \"success\", \"message\": \"$RESPONSE\"}" else curl -sS -X POST "${RUNTIME_API}/invocation/${REQUEST_ID}/error" \ -d "{\"errorType\": \"PinUpdateError\", \"errorMessage\": \"$RESPONSE\"}" fi rm -f "$HEADERS" done process_event() { local EVENT="$1" local CERT_ARN="$ACM_CERTIFICATE_ARN" echo "Processing ACM renewal for certificate: $CERT_ARN" # Step 1: Retrieve TrustPin API token from Secrets Manager export TRUSTPIN_API_TOKEN=$(aws secretsmanager get-secret-value \ --secret-id "$API_TOKEN_SECRET_ARN" \ --query 'SecretString' --output text) # Step 2: Retrieve BYOK private key from Secrets Manager PRIVATE_KEY_FILE=$(mktemp /tmp/byok-key-XXXXXX.pem) aws secretsmanager get-secret-value \ --secret-id "$BYOK_KEY_SECRET_ARN" \ --query 'SecretString' --output text > "$PRIVATE_KEY_FILE" chmod 600 "$PRIVATE_KEY_FILE" # Step 3: Export the renewed certificate from ACM CERT_FILE=$(mktemp /tmp/cert-XXXXXX.pem) aws acm get-certificate \ --certificate-arn "$CERT_ARN" \ --query 'Certificate' --output text > "$CERT_FILE" # Step 4: Extract SPKI SHA-256 pin and expiration from the certificate SPKI_SHA256=$(openssl x509 -in "$CERT_FILE" -pubkey -noout | \ openssl pkey -pubin -outform der | \ openssl dgst -sha256 -binary | \ base64) # Convert expiration to ISO 8601 format (portable across Linux/macOS) EXPIRES_EPOCH=$(openssl x509 -in "$CERT_FILE" -noout -enddate | \ cut -d= -f2 | \ xargs -I{} date -jf "%b %d %H:%M:%S %Y %Z" "{}" +%s 2>/dev/null || \ date -d "$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)" +%s) EXPIRES=$(date -u -r "$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ date -u -d "@$EXPIRES_EPOCH" +%Y-%m-%dT%H:%M:%SZ) echo "Extracted SPKI SHA-256: ${SPKI_SHA256:0:20}..." echo "Certificate expires: $EXPIRES" # Step 5: Remove already-expired pins before adding the renewed one ./trustpin-cli projects cleanup \ "$TRUSTPIN_ORG_ID" "$TRUSTPIN_PROJECT_ID" # Step 6: Upsert the pin into TrustPin ./trustpin-cli projects upsert \ "$TRUSTPIN_ORG_ID" "$TRUSTPIN_PROJECT_ID" \ --domain "$DOMAIN" \ --pin "spki-sha256:${SPKI_SHA256}" \ --expires "$EXPIRES" \ --output json # Step 7: Sign and publish with BYOK private key ./trustpin-cli projects sign \ "$TRUSTPIN_ORG_ID" "$TRUSTPIN_PROJECT_ID" \ --private-key "$PRIVATE_KEY_FILE" # Cleanup sensitive files rm -f "$PRIVATE_KEY_FILE" "$CERT_FILE" echo "Pin updated and configuration published for $DOMAIN" }

Deployment

# 1. Create the deployment package mkdir -p build cp handler.sh build/bootstrap chmod +x build/bootstrap # Download the TrustPin CLI Linux binary into the package curl -L https://github.com/trustpin-cloud/homebrew-trustpin/releases/latest/download/trustpin-cli-linux-x64 \ -o build/trustpin-cli chmod +x build/trustpin-cli cd build && zip -r ../function.zip . && cd .. # 2. Store secrets in AWS Secrets Manager aws secretsmanager create-secret \ --name trustpin/api-token \ --secret-string "tp_your_token_here" aws secretsmanager create-secret \ --name trustpin/byok-private-key \ --secret-string file://private-key.pem # 3. Deploy with SAM sam deploy \ --template-file template.yaml \ --stack-name trustpin-acm-auto-pin \ --capabilities CAPABILITY_IAM \ --parameter-overrides \ TrustPinOrgId=fba3418e-b5ae-b273-4bab-6da6ae07ba99 \ TrustPinProjectId=9caaea0a-80ea-013e-4e7b-cee6bfb52b36 \ AcmCertificateArn=arn:aws:acm:us-east-1:123456789012:certificate/abc-def-123 \ Domain=api.example.com \ TrustPinApiTokenSecretArn=arn:aws:secretsmanager:us-east-1:123456789012:secret:trustpin/api-token-AbCdEf \ ByokPrivateKeySecretArn=arn:aws:secretsmanager:us-east-1:123456789012:secret:trustpin/byok-private-key-GhIjKl # 4. Test with a manual invocation aws lambda invoke \ --function-name trustpin-acm-pin-updater \ --payload '{"detail":{"ActionType":"RENEWAL"}}' \ response.json && cat response.json

What happens on ACM renewal:

  1. ACM renews the certificate (automatic, managed by AWS)
  2. EventBridge detects the ACM Certificate Action event with ActionType: RENEWAL
  3. Lambda is invoked automatically
  4. Lambda exports the renewed certificate from ACM via the AWS SDK
  5. Extracts the SPKI SHA-256 pin and expiration using openssl
  6. Removes already-expired pins with trustpin-cli projects cleanup
  7. Upserts the pin into TrustPin with trustpin-cli projects upsert
  8. Signs and publishes the configuration using the BYOK private key with trustpin-cli projects sign --private-key
  9. Mobile apps fetch the updated configuration from the CDN on their next refresh

Critical considerations:

⚠️ ACM Key Rotation: ACM generates new key pairs on every renewal (key reuse is not supported). This means the SPKI pin will change on every renewal. To prevent mobile app pinning failures:

  1. Maintain dual pins: Keep both old and new certificate pins active during the transition period
  2. Monitor app refresh cycles: Ensure sufficient overlap time for all active app instances to fetch the new configuration
  3. Set appropriate overlap windows:
    • Recommended: 7-14 days overlap between old and new pins for mobile apps
    • Minimum: Consider your app’s usage patterns. If users don’t open the app for weeks, a longer overlap is critical
    • Automated environments: For server-to-server or CI/CD deployments with guaranteed refresh cycles, shorter overlaps (hours to days) may be acceptable

refresh-certs gives you dual pins automatically. Because the lookup returns every unexpired issuance TrustPin can see, not just the one currently being served, a scheduled refresh pins the new certificate as soon as it appears in Certificate Transparency, while the old pin stays in place until it expires. That’s the overlap window described below, maintained without any bookkeeping on your side.

Recommended dual-pin strategy:

# Before renewal (old cert expires in 30 days) # Old pin: expires 2026-03-01 # Status: Active # After renewal detected (new cert issued) # Old pin: expires 2026-03-01 (keep active) # New pin: expires 2027-03-01 (newly added) # Status: Both active for 30 days # After old cert expires (2026-03-01) # New pin: expires 2027-03-01 # Status: Old pin automatically ignored by SDKs

This ensures zero downtime during certificate renewals and accounts for mobile apps that haven’t fetched the updated configuration yet.


Troubleshooting

Authentication Issues

Problem: HTTP 401 Unauthorized

Solutions:

# Verify token is set correctly echo $TRUSTPIN_API_TOKEN # Test authentication trustpin-cli projects list --output json # Reconfigure CLI trustpin-cli configure

In CI/CD:

  • Verify TRUSTPIN_API_TOKEN secret is set correctly
  • Ensure token hasn’t expired (create new token if needed)
  • Check token has access to the organization/project

Validation Errors

Problem: Invalid pin format or type

❌ Validation failed: invalid pin type: sha255 Valid types: sha256, sha512, spki-sha256, spki-sha512

Solution: Fix typo in pin type (e.g., sha255sha256)

Problem: Invalid Base64 encoding

❌ Validation failed: invalid base64 encoding in pin value

Solution: Ensure hash is Base64-encoded, not hex:

# Correct: Base64 encoding openssl dgst -sha256 -binary | base64 # Incorrect: hex encoding openssl dgst -sha256

Problem: Wrong hash length

❌ Validation failed: invalid sha256 hash length: expected 32 bytes, got 16 bytes

Solution: Verify you’re hashing the correct data:

# For SPKI pins (correct) openssl x509 -in cert.pem -pubkey -noout | \ openssl pkey -pubin -outform der | \ openssl dgst -sha256 -binary | base64 # For certificate pins (correct) openssl x509 -in cert.pem -outform der | \ openssl dgst -sha256 -binary | base64

Private Key Issues (BYOK)

Problem: Failed to decrypt private key

❌ Failed to decrypt private key: cipher: message authentication failed

Solutions:

  • Verify master password is correct for cloud-managed keys
  • Verify private key password is correct for BYOK
  • Check private key file is in PEM format: file private-key.pem

Problem: Private key format errors in CI/CD

# Verify key is properly decoded file private-key.pem # Should show: "PEM RSA private key" or similar # Check file permissions ls -la private-key.pem # Should be 600 or 400

Network Issues

Problem: Connection timeout or network errors

Solutions:

# Test API connectivity curl -H "Authorization: Bearer $TRUSTPIN_API_TOKEN" \ https://api.trustpin.cloud/users # Check API base URL echo $TRUSTPIN_API_BASE_URL # Use verbose mode for debugging trustpin-cli projects sign <org-id> <project-id> --verbose

Upsert Operation Issues

Problem: Upsert succeeds but nothing changes

Reason: The pin already exists with the same expiration (idempotent operation)

Response:

{ "status": "success", "operation": "upsert", "action": "no_change", "message": "Pin already exists with same expiration" }

Solution: This is expected behavior. No action needed.

Refresh-Certs Issues

Problem: refresh-certs reports no certificates and changes nothing

Domain: api.example.com 0 certificate(s) returned by TrustPin No changes made

Reason: The lookup found nothing: the domain didn’t resolve, the TLS handshake failed, or there are no unexpired Certificate Transparency records for it.

This is fail-closed behavior, not a partial failure. Nothing is written when the lookup comes back empty, including --remove-expired removals, so a transient DNS or TLS blip can never strip a domain’s pins.

Solutions:

# Confirm what TrustPin can actually see for the domain trustpin-cli domains certificates api.example.com # Check the domain resolves and serves TLS from where you're running openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null # Inspect the request/response trustpin-cli projects refresh-certs $ORG_ID $PROJECT_ID \ --domain api.example.com --verbose --log-http

If the host is internal-only or otherwise not observable by TrustPin, use projects upsert with a pin you extract yourself.


Problem: refresh-certs runs successfully but the configuration version didn’t change

Reason: Every certificate returned was already pinned with a matching expiry. The command sends no request when there’s nothing to change, so the version isn’t bumped for no reason.

Solution: Expected behavior. This is what makes the command safe to run on a schedule. Use the version comparison shown in the scheduled refresh workflow to decide whether a sign is needed.


Problem: Expired pins on other domains are still there after --remove-expired

Reason: --remove-expired is scoped to the single domain named by --domain.

Solution: Use projects cleanup to sweep expired pins across every domain in the project.

Signing Preflight Failures

Problem: projects sign --dry-run fails with INVALID_KEY_PAIR

Reason: The private key you supplied doesn’t correspond to the public key registered for this project.

Solutions:

# Compare your key's public half against the project's registered public key openssl pkey -in private-key.pem -pubout trustpin-cli projects get $ORG_ID $PROJECT_ID --output json | \ jq -r '.data.project.public_key'

Check you’re not pointing a key for one project at a different project ID. This is the most common cause in multi-project pipelines.

Problem: projects sign --dry-run fails with INCORRECT_PASSWORD

Reason: Wrong master password (cloud keys) or wrong private key password (BYOK).

Solution: Verify the secret your pipeline injects. Because a dry run publishes nothing, you can iterate on this safely against the real project.

Exit Codes

The CLI uses standard exit codes for automation:

CodeMeaningExample
0Success or no-opPin added/updated, or already exists
1Configuration errorCLI not configured
2API error401, 404, 500 HTTP errors
3Authentication errorInvalid or revoked token
4Validation errorInvalid domain, pin format, or type
5Resource not foundWrong organization or project ID
6Key errorBad private key
7File errorCannot read/write a file
8Cryptography errorSignature verification or decryption failed
9Cancelled by userInteractive prompt aborted
10Permission errorInsufficient access rights
99Unexpected errorNetwork timeout, unknown failure

Use in scripts:

if ! trustpin-cli projects upsert ... --output json > result.json; then EXIT_CODE=$? echo "❌ Upsert failed with exit code $EXIT_CODE" cat result.json exit $EXIT_CODE fi

Getting Help

View command help:

trustpin-cli --help trustpin-cli projects --help trustpin-cli projects upsert --help trustpin-cli projects refresh-certs --help trustpin-cli projects sign --help

Enable verbose output:

trustpin-cli projects upsert ... --verbose trustpin-cli projects refresh-certs ... --verbose trustpin-cli projects sign ... --verbose

Check CLI version:

trustpin-cli --version

For additional support, contact support@trustpin.cloud or visit the TrustPin Console .