Skip to content

How credctl Uses the macOS Secure Enclave for Cloud Credentials

This post explains the architecture of credctl — a CLI tool that replaces long-lived AWS access keys with short-lived, hardware-bound credentials using the macOS Secure Enclave. For the problem statement and product overview, read Eliminating Plaintext Cloud Keys. This post is the “how.”

The Secure Enclave is a hardware security subsystem isolated from the main processor. On Apple Silicon Macs (M1–M4), it’s a dedicated core within the SoC. On Intel Macs with T2, it’s a separate chip. Either way, it has its own boot process, its own encrypted memory, and its own secure storage.

What matters for credctl:

  • Key generation happens inside the hardware. When you ask the Secure Enclave to create a key pair, the private key is generated inside the chip and never leaves it. There is no API to export it. There is no memory region to read it from. The hardware enforces this — it’s not a software policy.
  • Signing happens inside the hardware. You send data to the Secure Enclave, it signs it with the private key, and returns the signature. The private key is never exposed to the application, the operating system, or the CPU.
  • User presence verification. The Secure Enclave gates key operations behind biometric (Touch ID) or passcode verification. Even with root access, a process cannot silently use a Secure Enclave key without the user’s explicit approval.
  • FIPS 140-2 validated. Apple’s corecrypto module, which underpins Secure Enclave operations, has FIPS 140-2 validation. This is the same level of certification used by government and financial systems.

The Secure Enclave supports ECDSA with the P-256 curve (also known as secp256r1 or prime256v1). This maps directly to the ES256 algorithm in JWTs — which is exactly what credctl needs.

credctl is a Go CLI that bridges the Secure Enclave’s cryptographic capabilities to cloud provider credential APIs. The architecture has five components:

graph TB
subgraph "credctl CLI"
CMD[Command Layer<br/>cobra]
CFG[Config Manager<br/>~/.credctl/config.json]
ENC[Enclave Interface<br/>cgo → Security.framework]
JWT[JWT Builder<br/>ES256 tokens]
STS_C[AWS STS Client<br/>AssumeRoleWithWebIdentity]
end
subgraph "Hardware"
SE[Secure Enclave<br/>ECDSA P-256]
end
subgraph "AWS"
STS[AWS STS]
OIDC_P[OIDC Provider<br/>S3 + CloudFront]
end
CMD --> CFG
CMD --> ENC
CMD --> JWT
CMD --> STS_C
ENC --> SE
JWT --> ENC
STS_C --> STS
STS --> OIDC_P

This is the critical piece. The Secure Enclave is accessed through Apple’s Security framework, which is an Objective-C/C API. Go doesn’t call C APIs natively, so credctl uses cgo — Go’s foreign function interface for C code.

The Enclave interface abstracts hardware operations:

type Enclave interface {
Available() bool
GenerateKey(tag string) (*ecdsa.PublicKey, error)
LoadKey(tag string) (*ecdsa.PublicKey, error)
DeleteKey(tag string) error
Sign(tag string, data []byte) ([]byte, error)
}

On macOS, the implementation calls Security framework functions via cgo:

  • GenerateKey calls SecKeyCreateRandomKey with kSecAttrTokenIDSecureEnclave to create an ECDSA P-256 key pair inside the Secure Enclave. The key is tagged with a unique identifier so it can be loaded later. The function returns the public key — the private key stays in hardware.
  • Sign calls SecKeyCreateSignature with the kSecKeyAlgorithmECDSASignatureMessageX962SHA256 algorithm. The Secure Enclave signs the data and returns the DER-encoded signature. By default (--biometric=any), this triggers a Touch ID prompt with passcode fallback. This behaviour is configurable via the --biometric flag at credctl init time.
  • LoadKey retrieves a previously generated key by its tag using SecItemCopyMatching.

Build tags dispatch the implementation:

  • darwin.go — the real Secure Enclave implementation via cgo
  • other.go — a stub that returns clear errors on unsupported platforms

This cgo dependency is a deliberate architectural choice. It means credctl can’t be cross-compiled with a simple go build — it needs Xcode and an Apple provisioning profile. That’s a trade-off: harder distribution in exchange for real hardware security. The binary is distributed as a code-signed, notarised macOS app bundle.

This is where the Secure Enclave connects to AWS. The flow uses OIDC (OpenID Connect) federation — specifically, AWS STS AssumeRoleWithWebIdentity. Here’s how the pieces fit together:

  1. credctl init generates the key pair in the Secure Enclave. It exports the public key to ~/.credctl/device.pub and derives a device ID (SHA-256 fingerprint of the public key).

  2. credctl setup aws deploys a CloudFormation stack that creates:

    • An S3 bucket to host OIDC discovery documents
    • A CloudFront distribution in front of S3 (AWS requires OIDC issuers to use HTTPS)
    • An IAM OIDC identity provider that trusts the CloudFront URL as an OIDC issuer
    • An IAM role with a trust policy that allows AssumeRoleWithWebIdentity from the OIDC provider

    The command also generates and uploads the OIDC discovery documents (.well-known/openid-configuration and keys.json JWKS) automatically.

After setup, you have a self-hosted OIDC provider backed by your device’s hardware identity. AWS trusts this provider because it’s registered as an IAM OIDC identity provider.

sequenceDiagram
participant CLI as credctl CLI
participant SE as Secure Enclave
participant STS as AWS STS
participant CF as CloudFront<br/>(OIDC Provider)
CLI->>CLI: Build JWT claims:<br/>iss = CloudFront URL<br/>sub = device ID (SHA-256)<br/>aud = sts.amazonaws.com<br/>iat = now<br/>exp = now + 5min<br/>jti = random UUID
CLI->>SE: Sign JWT (ES256)
Note over SE: Touch ID prompt
SE-->>CLI: Signature
CLI->>CLI: Assemble signed JWT<br/>(header.payload.signature)
CLI->>STS: AssumeRoleWithWebIdentity<br/>(JWT + role ARN)
STS->>CF: GET /.well-known/openid-configuration
CF-->>STS: Discovery document
STS->>CF: GET /keys.json
CF-->>STS: JWKS (device public key)
STS->>STS: Verify JWT signature<br/>against JWKS public key
STS->>STS: Validate claims<br/>(issuer, audience, expiry)
STS->>STS: Assume IAM role
STS-->>CLI: AccessKeyId +<br/>SecretAccessKey +<br/>SessionToken<br/>(valid 1 hour)
  1. credctl auth builds a JWT with these claims:

    • iss — the CloudFront URL (OIDC issuer)
    • sub — the device ID (SHA-256 fingerprint of the public key)
    • audsts.amazonaws.com
    • iat — current timestamp
    • exp — current timestamp + 5 minutes (short-lived token)
    • jti — a random UUID (prevents replay)
  2. The JWT header specifies alg: ES256 and includes a kid (key ID) matching the key in the JWKS.

  3. The CLI sends the JWT payload to the Secure Enclave for signing. With the default biometric policy (--biometric=any), this triggers a Touch ID prompt with passcode fallback. The Secure Enclave returns an ECDSA signature.

  4. The CLI assembles the complete JWT (header.payload.signature, base64url-encoded) and calls STS AssumeRoleWithWebIdentity with the JWT and the IAM role ARN.

  5. STS fetches the OIDC discovery document from CloudFront, follows it to the JWKS endpoint, retrieves the device’s public key, and validates the JWT signature.

  6. If validation passes and the role’s trust policy allows it, STS returns temporary credentials — an access key ID, secret access key, and session token valid for one hour (default, configurable up to 12 hours).

AWS offers two X.509-based credential mechanisms: OIDC federation (via AssumeRoleWithWebIdentity) and IAM Roles Anywhere (via CreateSession). credctl uses OIDC federation. Here’s why:

Portability. OIDC is a cross-cloud standard. GCP’s Workload Identity Federation and Azure’s Federated Identity Credentials both accept OIDC tokens. By building on OIDC, credctl’s architecture extends to multi-cloud without fundamental changes. Roles Anywhere is AWS-specific.

Simplicity. OIDC federation requires hosting two static JSON files. Roles Anywhere requires managing X.509 certificates, certificate authorities, and certificate lifecycle. For a developer workstation tool, the OIDC approach is simpler.

JWT ecosystem. OIDC uses JWTs, which are well-understood, easy to debug (paste into jwt.io), and have mature library support. X.509 certificate handling is more complex and error-prone.

Existing infrastructure. The Secure Enclave supports ECDSA P-256 signing, which maps directly to ES256 JWTs. No certificate generation or CA infrastructure needed.

The trade-off: OIDC requires hosting a discovery endpoint (the S3 + CloudFront stack). Roles Anywhere doesn’t require any hosted infrastructure — it uses the X.509 certificate directly. For credctl’s use case, the simplicity and portability of OIDC outweigh the small infrastructure requirement.

Code Walkthrough: Key Generation to Credential Exchange

Section titled “Code Walkthrough: Key Generation to Credential Exchange”

Here’s the flow through credctl’s code, step by step.

The init command calls the Enclave interface to generate a key:

Enclave.GenerateKey(tag) → *ecdsa.PublicKey

Under the hood (on macOS), this calls SecKeyCreateRandomKey with attributes specifying:

  • Token: kSecAttrTokenIDSecureEnclave (create in Secure Enclave, not software)
  • Key type: kSecAttrKeyTypeECSECPrimeRandom (ECDSA)
  • Key size: 256 bits (P-256 curve)
  • Access control: determined by the --biometric flag (default: any)

The --biometric flag controls the Secure Enclave access control policy for signing:

--biometric valueAccess controlBehaviour
any (default)kSecAccessControlPrivateKeyUsage + kSecAccessControlUserPresenceTouch ID with passcode fallback
fingerprintkSecAccessControlPrivateKeyUsage + kSecAccessControlBiometryCurrentSetTouch ID only, no passcode fallback. Key is invalidated if fingerprints change.
nonekSecAccessControlPrivateKeyUsage onlyNo user verification. Signing happens silently when the device is unlocked.

The biometric policy is set at key creation time and cannot be changed afterward. To change it, reinitialise with credctl init --force --biometric=<policy>.

The public key is exported to ~/.credctl/device.pub in PEM format. The device ID is the SHA-256 fingerprint of the public key bytes. The config is written to ~/.credctl/config.json.

The JWT builder constructs the token in three parts:

Header:

{
"alg": "ES256",
"typ": "JWT",
"kid": "<key-id-matching-jwks>"
}

Payload:

{
"iss": "https://d1234.cloudfront.net",
"sub": "sha256:a1b2c3d4e5f6...",
"aud": "sts.amazonaws.com",
"iat": 1709800000,
"exp": 1709800300,
"jti": "550e8400-e29b-41d4-a716-446655440000"
}

The header and payload are base64url-encoded and concatenated with a dot: base64url(header).base64url(payload). This becomes the signing input.

The signing input bytes are sent to the Secure Enclave:

Enclave.Sign(tag, signingInput) → []byte (DER-encoded signature)

The Secure Enclave computes the ECDSA-SHA256 signature and returns it. The DER-encoded signature is converted to the raw r || s format that JWTs expect, then base64url-encoded.

The final JWT: base64url(header).base64url(payload).base64url(signature)

The CLI calls AWS STS AssumeRoleWithWebIdentity via a raw HTTP POST (no AWS SDK):

POST https://sts.amazonaws.com/
Content-Type: application/x-www-form-urlencoded
Action=AssumeRoleWithWebIdentity
&RoleArn=arn:aws:iam::123456789012:role/credctl-role
&RoleSessionName=credctl-session
&WebIdentityToken=<signed-jwt>
&Version=2011-06-15

STS validates the JWT by:

  1. Fetching https://d1234.cloudfront.net/.well-known/openid-configuration
  2. Following the jwks_uri to https://d1234.cloudfront.net/keys.json
  3. Matching the kid in the JWT header to a key in the JWKS
  4. Verifying the ES256 signature against the public key
  5. Checking claims: issuer matches, audience is sts.amazonaws.com, token is not expired

If valid, STS assumes the IAM role and returns temporary credentials.

credctl’s architecture provides several security guarantees:

Private key never leaves hardware. The Secure Enclave enforces non-exportability at the hardware level. Even with root access, an attacker cannot read the private key. They would need physical possession of the specific device AND the user’s biometric (or passcode) to sign a JWT.

Short-lived credentials. The STS credentials returned by AssumeRoleWithWebIdentity are temporary — one hour by default, configurable up to 12 hours. There are no long-lived credentials on disk.

Replay protection. Each JWT includes a unique jti (JWT ID) and a short exp (5 minutes). Even if a JWT were intercepted, it would expire before it could be meaningfully reused.

User presence required (default). With the default biometric policy (--biometric=any), every signing operation requires Touch ID or passcode verification. Malware cannot silently use the Secure Enclave key to obtain credentials. This can be disabled with --biometric=none at credctl init time for headless environments, at the cost of losing user-presence protection.

Revocation via JWKS. To revoke a device’s access, remove its public key from the JWKS and delete the IAM OIDC identity provider trust. STS will no longer validate JWTs from that device.

No credential caching on disk. credctl does not cache STS credentials to disk in plaintext. The credentials are output to stdout or set as environment variables for the current session.

No security architecture is complete without stating its limitations:

  • A compromised device with the user present. If an attacker has remote access to a device and the user approves a Touch ID prompt (social engineering or confusion), the attacker gets valid short-lived credentials. The mitigation is that the credentials are short-lived and the attack window is narrow.
  • Credential misuse after issuance. Once STS credentials are issued, they’re standard AWS temporary credentials. If they’re logged, written to a file, or used in a shared terminal, they can be used by anyone until they expire.
  • OIDC provider compromise. If an attacker gains write access to the S3 bucket hosting the JWKS, they could add their own public key and sign JWTs that STS would accept. The CloudFormation stack configures bucket access controls, but the OIDC provider is a critical trust boundary. The brokered mode (planned) eliminates this risk by centralising OIDC management.

It’s worth comparing credctl’s security properties to common alternatives:

ApproachKey StorageExportable?Credential LifetimeDevice Binding
~/.aws/credentialsPlaintext fileYesPermanentNone
1Password Shell PluginsEncrypted software vaultYes (if vault decrypted)Permanent (stored key)None
AWS Identity CenterBrowser session cookieYes (cookie theft)8–12 hoursNone
credctlSecure Enclave hardwareNo (hardware-enforced)1 hour (STS)Hardware-bound

The fundamental difference is the transition from software-stored credentials (which can be copied) to hardware-bound credentials (which physically cannot be copied). This is not an incremental improvement — it’s an architectural change in where trust is anchored.

Credential issuance involves three operations:

  1. JWT construction: Microseconds. String formatting and base64 encoding.
  2. Secure Enclave signing: Typically 20–50ms for the cryptographic operation, plus Touch ID interaction time (user-dependent).
  3. STS API call: 100–300ms depending on network latency.

Total time from credctl auth to credentials: under one second of compute, plus Touch ID interaction. This is faster than browser-based SSO flows, which typically require 5–15 seconds of page loads, redirects, and MFA prompts.

STS caches the OIDC discovery document and JWKS, so subsequent credential requests in the same session don’t require CloudFront fetches. The signing operation itself is constant-time — the Secure Enclave processes ECDSA signatures at the same speed regardless of the data being signed.

The architecture is designed to extend in two directions:

Platform expansion. The Enclave interface abstracts hardware operations, so adding Linux TPM 2.0 support means implementing the same interface against go-tpm instead of Apple’s Security framework. The JWT builder, OIDC generator, and STS client remain unchanged. The same applies to Windows TPM via a Windows-specific build.

Multi-cloud. The OIDC-based architecture was chosen specifically because GCP Workload Identity Federation and Azure Federated Identity Credentials accept OIDC tokens. Adding GCP support means implementing a GCP token exchange client; adding Azure means implementing an Azure AD token exchange client. The hardware identity, JWT construction, and OIDC provider infrastructure remain the same.

Brokered mode. For teams, the CLI will authenticate to a centralised credctl broker instead of directly to cloud providers. The broker verifies device identity, evaluates policy (who can assume which roles from which devices), brokers credentials from cloud providers, and logs everything for audit. The hardware identity foundation is the same — the broker trusts the device’s signed assertion because it has the device’s public key in its registry.