App Security

The SecureTextAppConfigItem config type allows tenant admins to store sensitive values — API keys, passwords, connection strings — in app configuration without exposing them in plaintext.

The encryption model is asymmetric: your app generates an RSA keypair and publishes the public key in its manifest. When a tenant admin sets a secure_text config value, Benchling encrypts it using your public key before storing it. Your app retrieves the encrypted value via the API and decrypts it using the private key, which never leaves your infrastructure.


How It Works

StepDetail
1. App developer generates RSA keypair2048-bit RSA key in PEM format
2. Public key declared in app manifestUnder the security.publicKey field
3. Tenant admin sets a secure_text config valueEntered in the App Workspace Configuration tab
4. Benchling encrypts the valueRSA-OAEP-256 key encryption + A256GCM content encryption (JWE compact serialization)
5. App retrieves the encrypted value via APIVia ConfigItemStore.config_by_path() — the value field contains the JWE ciphertext
6. App decrypts using private keyPrivate key never leaves your secrets manager; decryption_provider.decrypt() handles JWE unwrapping

Step 1: Generate a Keypair

Generate a 2048-bit RSA keypair. Store the private key in your secrets manager immediately — it should never be committed to source control or bundled with your app.

Using openssl

# Generate private key
openssl genrsa -out private_key.pem 2048

# Extract public key
openssl rsa -in private_key.pem -pubout -out public_key.pem

Using Python

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)

public_key_pem = private_key.public_key().public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
).decode("utf-8")

private_key_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption()
).decode("utf-8")

# Store private_key_pem in your secrets manager
# Never log or persist locally
print(public_key_pem)  # Paste this into your manifest

Step 2: Add the Public Key to Your Manifest

Declare secure_text config items and add the public key under the security field:

manifestVersion: 1

info:
  name: My Integration
  description: Example app using encrypted config

security:
  publicKey: |
    -----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
    -----END PUBLIC KEY-----

configuration:
  - name: External API Key
    type: secure_text
    description: API key for the external system this app connects to
    requiredConfig: true

The security.publicKey field and any secure_text config items must be declared together. Benchling will reject a secure_text config item if no public key is present in the manifest.


Step 3: Implement a Decryption Provider

The SDK provides BaseKeyUnwrappingDecryptionProvider — an abstract base class that handles JWE decryption via python-jose. You implement one method, unwrap_key(), which receives the RSA-encrypted content encryption key as bytes and returns the decrypted key bytes using your private key from your secrets manager.

This design is intentional: the SDK never asks you to pass a raw private key. Your private key stays in your secrets manager; unwrap_key() is the bridge between Benchling's encrypted payload and your key infrastructure.

Install the required extra first:

pip install benchling-sdk[python-jose]

Example: Local PEM file (development only)

from benchling_sdk.apps.config.decryption_provider import (
    BaseKeyUnwrappingDecryptionProvider,
)
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes


class LocalPemDecryptionProvider(BaseKeyUnwrappingDecryptionProvider):
    def __init__(self, private_key_pem: str):
        super().__init__()
        self._private_key = load_pem_private_key(
            private_key_pem.encode("utf-8"), password=None
        )

    def unwrap_key(self, wrapped_key: bytes) -> bytes:
        return self._private_key.decrypt(
            wrapped_key,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None,
            ),
        )
⚠️

This pattern is for local development only. Loading a private key from a file or environment variable is not suitable for production. Use a secrets manager implementation instead.

Example: AWS KMS

import boto3
from benchling_sdk.apps.config.decryption_provider import (
    BaseKeyUnwrappingDecryptionProvider,
)


class KmsDecryptionProvider(BaseKeyUnwrappingDecryptionProvider):
    def __init__(self, kms_key_id: str, region: str = "us-east-1"):
        super().__init__()
        self._kms = boto3.client("kms", region_name=region)
        self._kms_key_id = kms_key_id

    def unwrap_key(self, wrapped_key: bytes) -> bytes:
        response = self._kms.decrypt(
            CiphertextBlob=wrapped_key,
            KeyId=self._kms_key_id,
            EncryptionAlgorithm="RSAES_OAEP_SHA_256",
        )
        return response["Plaintext"]

Step 4: Retrieve and Decrypt a Config Value

Use ConfigItemStore with BenchlingConfigProvider to fetch config items from Benchling's API. The secure_text value is returned as JWE ciphertext in the standard .value field — the same field used by every other config type. Pass the ciphertext to your decryption provider to get the plaintext.

from benchling_sdk.benchling import Benchling
from benchling_sdk.auth.client_credentials_oauth2 import ClientCredentialsOAuth2
from benchling_sdk.apps.config.framework import (
    ConfigItemStore,
    BenchlingConfigProvider,
)

benchling = Benchling(
    url="https://{tenant}.benchling.com",
    auth_method=ClientCredentialsOAuth2(...),
)

decryption_provider = KmsDecryptionProvider(
    kms_key_id="alias/my-benchling-key"
)

config_store = ConfigItemStore(
    BenchlingConfigProvider(benchling, app_id="app_xxxx")
)

# Retrieve the encrypted value
# .required() raises MissingRequiredConfigItemError if the item is not set
ciphertext = config_store.config_by_path(["External API Key"]).required().value()

# Decrypt to plaintext
plaintext_api_key = decryption_provider.decrypt(ciphertext)

Use .required().value() for config items declared with requiredConfig: true in your manifest. For optional items, use .value() directly — it returns None if the item is not set.


Testing

The SDK provides several helpers in benchling_sdk.apps.config.mock_config for testing config handling without real keys or a live Benchling tenant.

MockDecryptionProviderStatic

Always returns the same plaintext regardless of what ciphertext is passed. Useful when your test only involves a single secret:

from benchling_sdk.apps.config.mock_config import MockDecryptionProviderStatic

mock_provider = MockDecryptionProviderStatic("plaintext-test-api-key")
assert mock_provider.decrypt("any-ciphertext") == "plaintext-test-api-key"

MockDecryptionProviderMapped

Returns different plaintext values based on the input ciphertext. Useful when testing multiple secrets simultaneously:

from benchling_sdk.apps.config.mock_config import MockDecryptionProviderMapped

mock_provider = MockDecryptionProviderMapped({
    "encrypted-api-key": "plaintext-api-key",
    "encrypted-db-password": "plaintext-db-password",
})
assert mock_provider.decrypt("encrypted-api-key") == "plaintext-api-key"

MockConfigItemStore

The recommended testing pattern is to build a MockConfigItemStore from your manifest file, then use with_replacement() to inject specific values for secure_text items using mock_secure_text_app_config_item():

from benchling_sdk.apps.config.mock_config import (
    MockConfigItemStore,
    MockDecryptionProviderStatic,
    mock_secure_text_app_config_item,
)
from benchling_sdk.apps.helpers.manifest_helpers import manifest_from_file

# Load your real manifest to build a realistic mock config
manifest = manifest_from_file("path/to/manifest.yaml")
mock_store = MockConfigItemStore.from_manifest(manifest)

# Replace the secure_text item with a known ciphertext value
mock_store = mock_store.with_replacement(
    mock_secure_text_app_config_item(
        path=["External API Key"],
        value="mock-ciphertext-value",
    )
)

# Use MockDecryptionProviderStatic to map that ciphertext to a known plaintext
mock_provider = MockDecryptionProviderStatic("plaintext-test-api-key")

# In your test, decrypt exactly as you would in production
ciphertext = mock_store.config_by_path(["External API Key"]).required().value()
plaintext = mock_provider.decrypt(ciphertext)
assert plaintext == "plaintext-test-api-key"

MockConfigItemStore.from_manifest() generates randomized but valid mock values for all config items in your manifest. Use with_replacement() to selectively override specific items with controlled test values without having to mock the entire config manually.


Security Considerations

  • Never commit your private key. Store it in AWS KMS, Azure Key Vault, HashiCorp Vault, or equivalent. The unwrap_key() pattern is designed so your private key never needs to be present in application memory as a raw value.
  • Rotate keys by updating your manifest. To rotate, generate a new keypair, update publicKey in your manifest, re-upload the manifest, and have the tenant admin re-enter any secure_text config values. Benchling will re-encrypt them with the new public key automatically.
  • One keypair per app. All secure_text config items for a given app are encrypted with the same public key. If you need encrypted values accessible to different services with different key access, consider separate apps with separate manifests.
  • Do not log ciphertext values. Although they are encrypted, logging JWE tokens unnecessarily increases exposure surface.

Related