Skip to content

Apify Secret Inputs

This guide explains how Apify’s secret input feature works and provides detailed implementation pointers for the Elixir language.

  1. Overview
  2. How Secret Inputs Work
  3. Encryption Mechanism
  4. Decryption Process
  5. Environment Variables
  6. Data Formats
  7. Elixir Implementation Guide
  8. Testing
  9. References

Apify’s secret input feature allows you to mark specific input fields as sensitive. These fields are automatically encrypted before being stored and can only be decrypted within the Actor’s execution environment.

  • Dual Encryption: Uses PGP-like dual encryption (RSA + AES)
  • Type Support: Works with string, object, and array input types
  • Isolation: Each user/Actor combination has unique RSA keys
  • Security: Decryption keys only available during Actor execution via environment variables

Mark a field as secret in your Actor’s input schema:

{
"properties": {
"password": {
"title": "Password",
"type": "string",
"description": "A secret, encrypted input field",
"editor": "textfield",
"isSecret": true
}
}
}

When you save Actor input through the Apify Console or API:

  1. Detect Secret Fields: Apify identifies fields marked with isSecret: true
  2. Encrypt Values: Each secret value is encrypted using:
    • Random AES-256-GCM key and IV
    • Actor/user-specific RSA-2048 public key
  3. Store Encrypted: Encrypted values saved to key-value store in format:
    ENCRYPTED_VALUE:<encrypted_password>:<encrypted_data>

When your Actor calls getInput():

  1. Load Input: Read from key-value store (contains encrypted values)
  2. Check Environment: Look for decryption keys in environment variables
  3. Decrypt Fields: For each encrypted field:
    • Parse the encrypted value format
    • RSA decrypt the password buffer
    • AES-GCM decrypt the actual value
  4. Return Decrypted: Return input with decrypted secret fields

Symmetric Encryption (Data):

  • Algorithm: AES-256-GCM
  • Key Length: 32 bytes (256 bits)
  • IV Length: 16 bytes (128 bits)
  • Auth Tag: 16 bytes (appended to ciphertext)

Asymmetric Encryption (Key):

  • Algorithm: RSA-2048
  • Padding: OAEP (Optimal Asymmetric Encryption Padding)
  • Purpose: Encrypts the AES key + IV combination
1. Generate random 32-byte AES key
2. Generate random 16-byte IV
3. Encrypt data with AES-256-GCM:
- Result: ciphertext + 16-byte auth tag
4. Concatenate key + IV (48 bytes total)
5. RSA-encrypt the 48-byte password buffer
6. Base64 encode both encrypted password and encrypted value
7. Format as: ENCRYPTED_VALUE:<base64_password>:<base64_value>

This is what you need to implement in Elixir.

Format Pattern:

^(ENCRYPTED_VALUE|ENCRYPTED_JSON):(?:([a-f0-9]{10}):)?([A-Za-z0-9+/=]+):([A-Za-z0-9+/=]+)$

Components:

  • Prefix: ENCRYPTED_VALUE (strings) or ENCRYPTED_JSON (objects/arrays)
  • Schema Hash (optional): 10-character hex string (for objects/arrays)
  • Encrypted Password: Base64-encoded RSA-encrypted AES key + IV
  • Encrypted Value: Base64-encoded AES-GCM-encrypted data + auth tag

Input: Base64-encoded encrypted password

Process:

  1. Base64 decode → get encrypted password bytes
  2. RSA-2048 decrypt using private key (with passphrase)
  3. Result: 48-byte buffer
    • First 32 bytes: AES key
    • Last 16 bytes: IV

Validation:

  • Password buffer must be exactly 48 bytes
  • If not, decryption failed

Input: Base64-encoded encrypted value

Process:

  1. Base64 decode → get encrypted value bytes
  2. Split into:
    • Encrypted data: all bytes except last 16
    • Auth tag: last 16 bytes
  3. Decrypt using AES-256-GCM:
    • Key: from Step 2 (32 bytes)
    • IV: from Step 2 (16 bytes)
    • Auth tag: from this step (16 bytes)
    • Ciphertext: from this step (remaining bytes)
  • If ENCRYPTED_VALUE: Return decrypted string as-is
  • If ENCRYPTED_JSON: Parse decrypted string as JSON

Your Elixir Actor needs to read these environment variables:

Variable NameDescriptionExample
APIFY_INPUT_SECRETS_PRIVATE_KEY_FILEBase64-encoded RSA private key (encrypted with passphrase)LS0tLS1CRUdJTiBSU0E...
APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASEPassphrase to decrypt the private keypwd1234
  • During Actor execution: Always available
  • Local development: Not available (secrets won’t be decrypted)
  • Outside Actor run: Not available (security measure)
ENCRYPTED_VALUE:Hw/uqRMRNHmxXYYDJCyaQX6xcwUnVYQnH4fWIlKZL...:Kj8d9f...
ENCRYPTED_JSON:a1b2c3d4e5:Hw/uqRMRNHmxXYYDJCyaQX6xcwUnVYQnH4fWIlKZL...:Kj8d9f...

The schema hash (a1b2c3d4e5) is used to verify the field schema matches.

Before Encryption:

{
"username": "john_doe",
"password": "mySecretPassword123",
"apiCredentials": {
"key": "abc",
"secret": "xyz"
}
}

After Encryption:

{
"username": "john_doe",
"password": "ENCRYPTED_VALUE:Hw/uqRM...:Kj8d9f...",
"apiCredentials": "ENCRYPTED_JSON:a1b2c3d4e5:Hw/uqRM...:Kj8d9f..."
}

Add to your mix.exs:

defp deps do
[
{:jason, "~> 1.4"} # For JSON parsing
]
end

Erlang’s :crypto and :public_key modules are built-in.

defmodule Apify.Config do
@moduledoc "Read Apify environment variables"
def get_private_key do
System.get_env("APIFY_INPUT_SECRETS_PRIVATE_KEY_FILE")
end
def get_passphrase do
System.get_env("APIFY_INPUT_SECRETS_PRIVATE_KEY_PASSPHRASE")
end
def has_decryption_keys? do
get_private_key() != nil and get_passphrase() != nil
end
end
defmodule Apify.Crypto.RSA do
@moduledoc "RSA decryption using Erlang :public_key"
@doc """
Decrypt a password buffer using RSA private key
## Parameters
- encrypted_password_b64: Base64-encoded encrypted password
- private_key_b64: Base64-encoded encrypted private key
- passphrase: Passphrase for the private key
## Returns
- {:ok, <<32-byte key, 16-byte iv>>} on success
- {:error, reason} on failure
"""
def decrypt_password(encrypted_password_b64, private_key_b64, passphrase) do
with {:ok, encrypted_password} <- Base.decode64(encrypted_password_b64),
{:ok, private_key_pem} <- Base.decode64(private_key_b64),
{:ok, private_key} <- decode_private_key(private_key_pem, passphrase),
{:ok, decrypted} <- rsa_decrypt(encrypted_password, private_key) do
if byte_size(decrypted) == 48 do
{:ok, decrypted}
else
{:error, "Invalid password length: expected 48 bytes, got #{byte_size(decrypted)}"}
end
end
end
defp decode_private_key(pem_data, passphrase) do
try do
# Decode PEM
[pem_entry] = :public_key.pem_decode(pem_data)
# Decrypt private key with passphrase
private_key = :public_key.pem_entry_decode(pem_entry, passphrase)
{:ok, private_key}
rescue
e -> {:error, "Failed to decode private key: #{inspect(e)}"}
end
end
defp rsa_decrypt(encrypted_data, private_key) do
try do
# RSA decryption with OAEP padding
decrypted = :public_key.decrypt_private(
encrypted_data,
private_key,
# Note: You may need to adjust padding based on how Node.js crypto does it
# Common options: :rsa_pkcs1_oaep_padding, :rsa_pkcs1_padding
[{:rsa_pad, :rsa_pkcs1_oaep_padding}]
)
{:ok, decrypted}
rescue
e -> {:error, "RSA decryption failed: #{inspect(e)}"}
end
end
end
defmodule Apify.Crypto.AES do
@moduledoc "AES-256-GCM decryption"
@key_length 32
@iv_length 16
@auth_tag_length 16
@doc """
Decrypt value using AES-256-GCM
## Parameters
- encrypted_value_b64: Base64-encoded encrypted value (ciphertext + auth tag)
- password_buffer: 48-byte buffer containing key (32) + iv (16)
## Returns
- {:ok, decrypted_string} on success
- {:error, reason} on failure
"""
def decrypt_value(encrypted_value_b64, password_buffer) do
with {:ok, encrypted_value} <- Base.decode64(encrypted_value_b64),
{:ok, {key, iv, ciphertext, auth_tag}} <- parse_encrypted_value(encrypted_value, password_buffer),
{:ok, decrypted} <- aes_gcm_decrypt(ciphertext, key, iv, auth_tag) do
{:ok, decrypted}
end
end
defp parse_encrypted_value(encrypted_value, password_buffer) do
if byte_size(encrypted_value) < @auth_tag_length do
{:error, "Encrypted value too short"}
else
# Split password buffer into key and IV
<<key::binary-size(@key_length), iv::binary-size(@iv_length)>> = password_buffer
# Split encrypted value into ciphertext and auth tag
ciphertext_size = byte_size(encrypted_value) - @auth_tag_length
<<ciphertext::binary-size(ciphertext_size), auth_tag::binary-size(@auth_tag_length)>> = encrypted_value
{:ok, {key, iv, ciphertext, auth_tag}}
end
end
defp aes_gcm_decrypt(ciphertext, key, iv, auth_tag) do
try do
# AES-256-GCM decryption using Erlang :crypto
decrypted = :crypto.crypto_one_time_aead(
:aes_256_gcm,
key,
iv,
ciphertext,
"", # Additional authenticated data (AAD) - empty in this case
auth_tag,
false # false = decrypt
)
{:ok, decrypted}
rescue
e -> {:error, "AES-GCM decryption failed: #{inspect(e)}"}
end
end
end
defmodule Apify.SecretInput do
@moduledoc "Decrypt Apify secret input fields"
@encrypted_value_prefix "ENCRYPTED_VALUE"
@encrypted_json_prefix "ENCRYPTED_JSON"
@encrypted_regex ~r/^(ENCRYPTED_VALUE|ENCRYPTED_JSON):(?:([a-f0-9]{10}):)?([A-Za-z0-9+/=]+):([A-Za-z0-9+/=]+)$/
@doc """
Decrypt all secret fields in the input object
## Parameters
- input: Map containing potentially encrypted fields
- private_key_b64: Base64-encoded private key
- passphrase: Passphrase for private key
## Returns
- Map with decrypted secret fields
"""
def decrypt_secrets(input, private_key_b64, passphrase) when is_map(input) do
Enum.reduce(input, %{}, fn {key, value}, acc ->
decrypted_value = decrypt_if_encrypted(value, private_key_b64, passphrase)
Map.put(acc, key, decrypted_value)
end)
end
def decrypt_secrets(input, _, _), do: input
defp decrypt_if_encrypted(value, private_key_b64, passphrase) when is_binary(value) do
case Regex.run(@encrypted_regex, value) do
[_, prefix, _schema_hash, encrypted_password, encrypted_value] ->
decrypt_encrypted_field(prefix, encrypted_password, encrypted_value, private_key_b64, passphrase)
nil ->
# Not encrypted, return as-is
value
end
end
defp decrypt_if_encrypted(value, _, _), do: value
defp decrypt_encrypted_field(prefix, encrypted_password, encrypted_value, private_key_b64, passphrase) do
with {:ok, password_buffer} <- Apify.Crypto.RSA.decrypt_password(
encrypted_password,
private_key_b64,
passphrase
),
{:ok, decrypted_string} <- Apify.Crypto.AES.decrypt_value(encrypted_value, password_buffer) do
# Parse based on prefix
case prefix do
@encrypted_value_prefix ->
# Return as string
{:ok, decrypted_string}
@encrypted_json_prefix ->
# Parse as JSON
case Jason.decode(decrypted_string) do
{:ok, json_value} -> {:ok, json_value}
{:error, _} = error -> error
end
end
else
{:error, reason} ->
# Log error and return original encrypted value
IO.warn("Failed to decrypt secret field: #{reason}")
{:error, reason}
end
end
@doc """
Check if a value is encrypted
"""
def encrypted?(value) when is_binary(value) do
Regex.match?(@encrypted_regex, value)
end
def encrypted?(_), do: false
end
defmodule Apify.Input do
@moduledoc "Read and decrypt Apify Actor input"
@doc """
Get Actor input, automatically decrypting secret fields if keys are available
## Returns
- {:ok, input_map} on success
- {:error, reason} on failure
"""
def get_input do
# Read input from key-value store (you need to implement this)
with {:ok, raw_input} <- read_raw_input() do
# Check if we have decryption keys
if Apify.Config.has_decryption_keys?() do
decrypt_input(raw_input)
else
# No keys available, return raw input
{:ok, raw_input}
end
end
end
defp decrypt_input(raw_input) when is_map(raw_input) do
private_key = Apify.Config.get_private_key()
passphrase = Apify.Config.get_passphrase()
decrypted = Apify.SecretInput.decrypt_secrets(
raw_input,
private_key,
passphrase
)
{:ok, decrypted}
end
defp decrypt_input(raw_input), do: {:ok, raw_input}
defp read_raw_input do
# TODO: Implement reading from Apify key-value store
# This will depend on how you access the KVS in your Elixir implementation
# Example:
# - Read from file: /apify_storage/key_value_stores/default/INPUT.json
# - Or use Apify API client
{:ok, %{}}
end
end
# In your Actor's main module
defmodule MyActor do
def run do
case Apify.Input.get_input() do
{:ok, input} ->
IO.inspect(input, label: "Decrypted input")
# Access secret fields normally
password = input["password"]
IO.puts("Password: #{password}")
{:error, reason} ->
IO.puts("Failed to get input: #{reason}")
end
end
end

The JavaScript SDK test suite includes test keys you can use:

Public Key (for reference):

# Base64-encoded RSA public key
public_key_b64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF0dis3NlNXbklhOFFKWC94RUQxRQpYdnBBQmE3ajBnQnVYenJNUU5adjhtTW1RU0t2VUF0TmpOL2xacUZpQ0haZUQxU2VDcGV1MnFHTm5XbGRxNkhUCnh5cXJpTVZEbFNKaFBNT09QSENISVNVdFI4Tk5lR1Y1MU0wYkxJcENabHcyTU9GUjdqdENWejVqZFRpZ1NvYTIKQWxrRUlRZWQ4UVlDKzk1aGJoOHk5bGcwQ0JxdEdWN1FvMFZQR2xKQ0hGaWNuaWxLVFFZay9MZzkwWVFnUElPbwozbUppeFl5bWFGNmlMZTVXNzg1M0VHWUVFVWdlWmNaZFNjaGVBMEdBMGpRSFVTdnYvMEZjay9adkZNZURJOTVsCmJVQ0JoQjFDbFg4OG4wZUhzUmdWZE5vK0NLMDI4T2IvZTZTK1JLK09VaHlFRVdPTi90alVMdGhJdTJkQWtGcmkKOFFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="

Private Key File (base64-encoded, encrypted):

# This is the base64-encoded PEM file (which itself is encrypted)
private_key_file_b64 = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpQcm9jLVR5cGU6IDQsRU5DUllQVEVECkRFSy1JbmZvOiBERVMtRURFMy1DQkMsNTM1QURERjIzNUQ4QkFGOQoKMXFWUzl0S0FhdkVhVUVFMktESnpjM3plMk1lZkc1dmVEd2o1UVJ0ZkRaMXdWNS9VZmIvcU5sVThTSjlNaGhKaQp6RFdrWExueUUzSW0vcEtITVZkS0czYWZkcFRtcis2TmtidXptd0dVMk0vSWpzRjRJZlpad0lGbGJoY09jUnp4CmZmWVIvTlVyaHNrS1RpNGhGV0lBUDlLb3Z6VDhPSzNZY3h6eVZQWUxYNGVWbWt3UmZzeWkwUU5Xb0tGT3d0ZC8KNm9HYzFnd2piRjI5ZDNnUThZQjFGWmRLa1AyMTJGbkt1cTIrUWgvbE1zTUZrTHlTQTRLTGJ3ZG1RSXExbE1QUwpjbUNtZnppV3J1MlBtNEZoM0dmWlQyaE1JWHlIRFdEVzlDTkxKaERodExOZ2RRamFBUFpVT1E4V2hwSkE5MS9vCjJLZzZ3MDd5Z2RCcVd5dTZrc0pXcjNpZ1JpUEJ5QmVNWEpEZU5HY3NhaUZ3Q2c5eFlja1VORXR3NS90WlRsTjIKSEdZV0NpVU5Ed0F2WllMUHR1SHpIOFRFMGxsZm5HR0VuVC9QQlp1UHV4andlZlRleE1mdzFpbGJRU3lkcy9HMgpOOUlKKzkydms0N0ZXR2NOdGh1Q3lCbklva0NpZ0c1ZlBlV2IwQTdpdjk0UGtwRTRJZ3plc0hGQ0ZFQWoxWldLCnpQdFRBQlkwZlJrUzBNc3UwMHYxOXloTTUrdFUwYkVCZWo2eWpzWHRoYzlwS01hcUNIZWlQTC9TSHRkaWsxNVMKQmU4Sml4dVJxZitUeGlYWWVuNTg2aDlzTFpEYzA3cGpkUGp2NVNYRnBYQjhIMlVxQ0tZY2p4R3RvQWpTV0pjWApMNHc3RHNEby80bVg1N0htR09iamlCN1ZyOGhVWEJDdFh2V0dmQXlmcEFZNS9vOXowdm4zREcxaDc1NVVwdDluCkF2MFZrbm9qcmJVYjM1ZlJuU1lYTVltS01LSnpNRlMrdmFvRlpwV0ZjTG10cFRWSWNzc0JGUEYyZEo3V1c0WHMKK0d2Vkl2eFl3S2wyZzFPTE1TTXRZa09vekdlblBXTzdIdU0yMUVKVGIvbHNEZ25GaTkrYWRGZHBLY3R2cm0zdgpmbW1HeG5pRmhLU05GU0xtNms5YStHL2pjK3NVQVBhb2FZNEQ3NHVGajh0WGp0eThFUHdRRGxVUGRVZld3SE9PClF3bVgyMys1REh4V0VoQy91Tm8yNHNNY2ZkQzFGZUpBV281bUNuVU5vUVVmMStNRDVhMzNJdDhhMmlrNUkxUWoKeSs1WGpRaG0xd3RBMWhWTWE4aUxBR0toT09lcFRuK1VBZHpyS0hvNjVtYzNKbGgvSFJDUXJabnVxWkErK0F2WgpjeWU0dWZGWC8xdmRQSTdLb2Q0MEdDM2dlQnhweFFNYnp1OFNUcGpOcElJRkJvRVc5dFRhemUzeHZXWnV6dDc0CnFjZS8xWURuUHBLeW5lM0xGMk94VWoyYWVYUW5YQkpYcGhTZTBVTGJMcWJtUll4bjJKWkl1d09RNHV5dm94NjUKdG9TWGNac054dUs4QTErZXNXR3JSN3pVc0djdU9QQTFERE9Ja2JjcGtmRUxMNjk4RTJRckdqTU9JWnhrcWdxZQoySE5VNktWRmV2NzdZeEJDbm1VcVdXZEhYMjcyU2NPMUYzdWpUdFVnRVBNWGN0aEdBckYzTWxEaUw1Q0k0RkhqCnhHc3pVemxzalRQTmpiY2MzdUE2MjVZS3VVZEI2c1h1Rk5NUHk5UDgwTzBpRWJGTXl3MWxmN2VpdFhvaUUxWVoKc3NhMDVxTUx4M3pPUXZTLzFDdFpqaFp4cVJMRW5pQ3NWa2J..."

Passphrase:

passphrase = "pwd1234"
defmodule Apify.SecretInputTest do
use ExUnit.Case
test "decrypt encrypted string value" do
# You would need to generate test data using the JS SDK
# or use real encrypted values from Apify platform
input = %{
"username" => "john_doe",
"password" => "ENCRYPTED_VALUE:abc123:def456"
}
private_key = System.get_env("TEST_PRIVATE_KEY")
passphrase = System.get_env("TEST_PASSPHRASE")
result = Apify.SecretInput.decrypt_secrets(input, private_key, passphrase)
assert result["username"] == "john_doe"
assert result["password"] == "decrypted_password_here"
end
end
  1. JavaScript SDK - Actor Input Decryption

    • File: src/actor.ts:1347-1378
    • Function: Actor.getInput()
    • Shows how to load private key and decrypt input
  2. Input Secrets Package

    • File: node_modules/@apify/input_secrets/esm/index.mjs:111-134
    • Function: decryptInputSecrets()
    • Shows regex pattern and decryption logic
  3. Crypto Utilities

    • File: node_modules/@apify/utilities/esm/index.mjs:1756-1790
    • Functions: publicEncrypt() and privateDecrypt()
    • Shows exact encryption/decryption implementation
  4. Configuration

    • File: src/configuration.ts:164-166
    • Shows environment variable names
  • Implement Apify.Config module to read environment variables
  • Implement Apify.Crypto.RSA module for RSA decryption
    • Decode PEM-format private key
    • Handle passphrase-encrypted keys
    • RSA-2048 decryption with OAEP padding
  • Implement Apify.Crypto.AES module for AES-GCM decryption
    • Split password buffer into key and IV
    • Split encrypted value into ciphertext and auth tag
    • AES-256-GCM decryption
  • Implement Apify.SecretInput module
    • Regex pattern matching for encrypted values
    • Handle both ENCRYPTED_VALUE and ENCRYPTED_JSON prefixes
    • Recursively decrypt all fields in input object
  • Implement Apify.Input module
    • Read raw input from key-value store
    • Check for decryption keys
    • Decrypt if keys available
  • Add error handling and logging
  • Write unit tests with test data
  • Test with real Apify Actor runs
  1. RSA Padding: Ensure you use OAEP padding, not PKCS#1 v1.5
  2. Auth Tag Order: The auth tag is appended at the END of the encrypted value
  3. Base64 Encoding: Use standard Base64, not URL-safe variants
  4. Password Buffer Length: Must be exactly 48 bytes (32 + 16)
  5. JSON Parsing: Only parse if prefix is ENCRYPTED_JSON
  6. Private Key Format: The env var contains base64-encoded PEM, decode twice

If you encounter issues:

  1. Check that environment variables are set correctly
  2. Verify the private key passphrase is correct
  3. Ensure the encrypted value format matches the regex
  4. Check RSA padding settings match Node.js crypto behavior
  5. Verify AES-GCM auth tag is being handled correctly
  6. Compare with JavaScript SDK test cases in test/apify/actor.test.ts:1503-1534