A OneTimeSecret-like service built on AWS Lambda and Azure KeyVault that allows secure sharing of secrets with one-time access.
The Merabytes Secrets Sharing Service provides a simple yet secure way to share sensitive information. Secrets are:
- Stored securely in Azure KeyVault
- Identified by a unique UUID
- Accessible only once (retrieved and immediately deleted)
This implementation follows the OneTimeSecret pattern where secrets self-destruct after being accessed.
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Frontend │────────▶│ AWS Lambda │────────▶│ Azure KeyVault │
│ │ │ (Secrets) │ │ │
└─────────────┘ └──────────────────┘ └─────────────────┘
│
│
┌─────▼──────┐
│ Generate │
│ UUID │
└────────────┘
- Create Secret: Store a secret and receive a unique UUID
- Retrieve Secret: Get the secret once using the UUID (auto-deletes after retrieval)
- Check Secret: Check if a secret is encrypted without retrieving it (non-destructive)
- Multi-layer Encryption: Two independent encryption layers for enhanced security
- System-level encryption with SECRET_KEY (always applied)
- Optional user-level encryption with password
- Password Encryption: Optional client-side password protection for secrets
- Time-based Expiration: Optional expiration time for automatic secret deletion
- Secure Storage: All secrets stored in Azure KeyVault with explicit encryption metadata
- Serverless: Runs on AWS Lambda with automatic scaling
- Continuous Deployment: Automated deployment via GitHub Actions
- Bot Protection: Optional CloudFlare Turnstile integration for spam prevention
The API provides three main operations:
- Create: Store a secret (with optional password encryption and/or expiration time) and get a UUID
- Check: Verify if a secret is encrypted and/or has expiration before attempting to retrieve it
- Retrieve: Get the secret once (with password if encrypted) and auto-delete
When creating a secret, the service stores up to three items in Azure KeyVault:
{uuid}- The actual secret value (always encrypted with SECRET_KEY, plus optional password encryption){uuid}-metadata- Encryption type marker:secret_key_encrypted- System-level encryption only (no password required)secret_key_password_encrypted- Both system-level and user password encryption- Legacy values:
encrypted(password only),plaintext(no encryption)
{uuid}-expires- (Optional) UNIX timestamp expiration
Multi-layer Encryption Process:
- User Password Layer (optional): If user provides a password, secret is first encrypted with AES-256
- System SECRET_KEY Layer (always): Secret is then encrypted with the system-level SECRET_KEY
- Storage: Doubly-encrypted secret is stored in Azure KeyVault
Decryption Process:
- System Layer: Secret is first decrypted using SECRET_KEY
- User Layer: If password was used, secret is then decrypted with the user's password
This ensures bulletproof detection of encryption status without content inspection, preventing false positives from base64-encoded data.
Secrets can be created with an optional expiration time:
- Provide
expires_atas a UNIX timestamp (seconds since epoch, e.g., 1735689599) - Expired secrets are automatically detected and deleted when accessed
- Returns HTTP 410 Gone for expired secrets
- Expiration is checked during both
checkandretrieveoperations
Store a new secret and receive a UUID to access it. Optionally encrypt with a password and/or set an expiration time.
Request (plaintext secret):
{
"action": "create",
"secret": "Your secret message here",
"turnstile_token": "cloudflare-turnstile-response-token"
}Request (password-encrypted secret):
{
"action": "create",
"secret": "Your secret message here",
"password": "your-encryption-password",
"turnstile_token": "cloudflare-turnstile-response-token"
}Request (secret with expiration time):
{
"action": "create",
"secret": "Your secret message here",
"expires_at": 1735689599,
"turnstile_token": "cloudflare-turnstile-response-token"
}Request (password-encrypted secret with expiration):
{
"action": "create",
"secret": "Your secret message here",
"password": "your-encryption-password",
"expires_at": 1735689599,
"turnstile_token": "cloudflare-turnstile-response-token"
}Response (201 Created):
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"message": "Secret created successfully"
}Response (201 Created - with expiration):
{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"message": "Secret created successfully",
"expires_at": 1735689599
}Notes:
- If
passwordis provided, the secret is encrypted with AES-256 using PBKDF2 key derivation - The same password must be provided during retrieval to decrypt
- Metadata is stored to track encryption status (bulletproof detection)
- If
expires_atis provided, the secret will automatically expire and be deleted at that time expires_atmust be a UNIX timestamp (seconds since epoch) and in the future- Expired secrets return a 410 Gone status and are automatically deleted
Response (400 Bad Request - Turnstile enabled, token missing):
{
"error": "Missing required field: turnstile_token (bot protection enabled)"
}Response (400 Bad Request - Invalid expiration):
{
"error": "expires_at must be in the future"
}Response (403 Forbidden - Invalid Turnstile token):
{
"error": "Invalid or expired Turnstile token"
}Check if a secret is encrypted without retrieving or deleting it. This is useful for frontend applications to conditionally render password input fields.
Request:
{
"action": "check",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"turnstile_token": "cloudflare-turnstile-response-token"
}Response (200 OK - Encrypted secret):
{
"encrypted": true,
"requires_password": true
}Response (200 OK - Plaintext secret):
{
"encrypted": false,
"requires_password": false
}Response (200 OK - Secret with expiration):
{
"encrypted": false,
"requires_password": false,
"expires_at": 1735689599
}Response (404 Not Found):
{
"error": "Secret not found or already accessed"
}Response (410 Gone - Secret expired):
{
"error": "Secret has expired and has been deleted",
"expired_at": 1735689599
}Notes:
- Non-destructive operation - secret remains accessible after check
- Uses stored metadata for bulletproof encryption detection
- No content inspection or heuristics - 100% reliable
- Frontend can use
requires_passwordto show/hide password input - If secret has expiration,
expires_atfield will be included in the response - Expired secrets return 410 Gone and are automatically deleted
Retrieve and delete a secret using its UUID (one-time access). Provide password if the secret is encrypted.
Request (plaintext secret):
{
"action": "retrieve",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"turnstile_token": "cloudflare-turnstile-response-token"
}Request (encrypted secret with password):
{
"action": "retrieve",
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"password": "your-encryption-password",
"turnstile_token": "cloudflare-turnstile-response-token"
}Response (200 OK):
{
"secret": "Your secret message here",
"message": "Secret retrieved and deleted successfully"
}Response (400 Bad Request - Missing password for encrypted secret):
{
"error": "Password required for encrypted secret"
}Response (400 Bad Request - Wrong password):
{
"error": "Decryption failed: Invalid password or corrupted data"
}Response (404 Not Found):
{
"error": "Secret not found or already accessed"
}Response (410 Gone - Secret expired):
{
"error": "Secret has expired and has been deleted",
"expired_at": 1735689599
}Notes:
- Secret is automatically deleted after successful retrieval (one-time access)
- If secret is encrypted, password must match the one used during creation
- Wrong password or missing password for encrypted secrets returns 400 error and secret remains accessible for retry
- Multiple password attempts are allowed until correct password is provided
- Secret is only deleted upon successful decryption and retrieval
- Expired secrets return 410 Gone status and are automatically deleted from storage
-
Create a plaintext secret:
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "create", "secret": "Meet me at the coffee shop at 3pm", "turnstile_token": "token..." }'
Response:
{"uuid": "abc-123", "message": "Secret created successfully"} -
Share the UUID with recipient:
https://yourapp.com/view/abc-123 -
Check if password is needed (optional):
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "check", "uuid": "abc-123", "turnstile_token": "token..." }'
Response:
{"encrypted": false, "requires_password": false} -
Retrieve the secret:
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "retrieve", "uuid": "abc-123", "turnstile_token": "token..." }'
Response:
{"secret": "Meet me at the coffee shop at 3pm", ...} -
Try again - fails with 404 (already accessed)
-
Create an encrypted secret:
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "create", "secret": "Database password: super_secret_123", "password": "myStrongPassword2024", "turnstile_token": "token..." }'
Response:
{"uuid": "xyz-789", "message": "Secret created successfully"} -
Share UUID and password separately:
- Email: "Here's the secret UUID: xyz-789"
- SMS: "Password: myStrongPassword2024"
-
Check encryption status:
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "check", "uuid": "xyz-789", "turnstile_token": "token..." }'
Response:
{"encrypted": true, "requires_password": true}(Frontend shows password input field) -
Retrieve with password:
curl -X POST https://lambda-url/secrets \ -H "Content-Type: application/json" \ -d '{ "action": "retrieve", "uuid": "xyz-789", "password": "myStrongPassword2024", "turnstile_token": "token..." }'
Response:
{"secret": "Database password: super_secret_123", ...} -
Wrong password - returns 400 but secret remains accessible for retry
📖 For detailed setup instructions, see MULTI_REGION_SETUP.md
The service supports multi-region deployment to AWS Lambda across multiple geographic regions, with each region using region-specific Azure Key Vault credentials. This architecture provides:
- Global availability: Deploy to 6 AWS regions (Europe, US, Asia-Pacific)
- Regional isolation: Each region has its own Azure Key Vault
- Improved performance: Users can connect to the nearest region
- High availability: Redundancy across multiple regions
Supported Regions:
| AWS Region | Azure Region Code | Geographic Location |
|---|---|---|
| eu-west-1 | WESTEUROPE | West Europe (Ireland) |
| eu-central-1 | GERMANYWESTCENTRAL | Germany West Central |
| us-east-1 | EASTUS | East US (Virginia) |
| us-west-2 | WESTUS2 | West US 2 (Washington) |
| ap-southeast-1 | SOUTHEASTASIA | Southeast Asia (Singapore) |
| ap-northeast-1 | JAPANEAST | Japan East (Tokyo) |
-
AWS Account with:
- ECR repository:
acido-secrets(in eu-west-1 for centralized image storage) - Lambda functions:
acido-secretsdeployed in each target region - Appropriate IAM permissions for multi-region deployment
- Lambda Function URLs configured for each regional Lambda (optional but recommended)
- ECR repository:
-
Azure Account with:
- One Key Vault created per region
- Service Principal with Key Vault access for each vault
- Each Key Vault should have appropriate permissions configured
-
GitHub Secrets configured (Global):
AWS_ACCESS_KEY_ID- AWS access key for deploymentAWS_SECRET_ACCESS_KEY- AWS secret key for deploymentCF_SECRET_KEY- CloudFlare Turnstile secret key (optional, for bot protection)CORS_ORIGIN- Comma-separated list of allowed CORS origins (optional, defaults to secrets.merabytes.com, app.merabytes.com, and local.merabytes.com)
The service automatically deploys to all configured AWS Lambda regions when changes are pushed to the main branch.
Deployment Process:
- Build Stage: Docker image is built once and pushed to ECR (eu-west-1)
- Deploy Stage: Image is deployed to Lambda functions in all 6 regions in parallel
- Configuration: Each region's Lambda is configured with region-specific Azure Key Vault credentials
- Testing: Health checks are performed on each regional deployment
- Summary: Deployment summary is generated with all regional endpoints
- JSON Output: A JSON dictionary of deployed function URLs is displayed in the format:
{"aws-region": "function-url"}
See .github/workflows/deploy.yml for the multi-region deployment workflow.
Deployment Output:
The GitHub Actions workflow automatically outputs a JSON dictionary of all deployed Lambda function URLs in the "Deployment Summary" job. This makes it easy to copy and use in your frontend application:
{
"eu-west-1": "https://abc123.lambda-url.eu-west-1.on.aws/",
"eu-central-1": "https://def456.lambda-url.eu-central-1.on.aws/",
"us-east-1": "https://ghi789.lambda-url.us-east-1.on.aws/",
"us-west-2": "https://jkl012.lambda-url.us-west-2.on.aws/",
"ap-southeast-1": "https://mno345.lambda-url.ap-southeast-1.on.aws/",
"ap-northeast-1": "https://pqr678.lambda-url.ap-northeast-1.on.aws/"
}After deployment, the function URLs are automatically collected and displayed in JSON format in the workflow logs. You can also retrieve them manually for each region:
# Get function URL for a specific region
aws lambda get-function-url-config \
--function-name AcidoSecrets \
--region eu-west-1 \
--query 'FunctionUrl' \
--output textFrontend Integration Example:
Create a dictionary mapping regions to Lambda URLs:
const REGION_ENDPOINTS = {
'eu-west-1': 'https://abc123.lambda-url.eu-west-1.on.aws/',
'eu-central-1': 'https://def456.lambda-url.eu-central-1.on.aws/',
'us-east-1': 'https://ghi789.lambda-url.us-east-1.on.aws/',
'us-west-2': 'https://jkl012.lambda-url.us-west-2.on.aws/',
'ap-southeast-1': 'https://mno345.lambda-url.ap-southeast-1.on.aws/',
'ap-northeast-1': 'https://pqr678.lambda-url.ap-northeast-1.on.aws/',
};
// Select nearest region or allow user to choose
const selectedRegion = 'eu-west-1';
const lambdaUrl = REGION_ENDPOINTS[selectedRegion];Two helper scripts are provided to assist with multi-region deployment:
Validates that all prerequisites are in place before deployment:
./scripts/validate-setup.shThis script checks:
- AWS and Azure CLI installation
- AWS and Azure credentials
- ECR repository existence
- Lambda functions in all regions
- Azure Key Vaults in all regions
- GitHub secrets configuration checklist
Retrieves Lambda Function URLs from all regions in various formats:
./scripts/get-function-urls.shOutput formats:
- JSON (for APIs)
- JavaScript (for frontend)
- Python (for backend)
- Table (for documentation)
Use this script after deployment to get the region endpoints dictionary for your frontend application.
- Build the Docker image:
docker build -t acido-secrets:latest -f Dockerfile.lambda.secrets .- Tag and push to ECR:
docker tag acido-secrets:latest <ECR_REGISTRY>/acido-secrets:latest
docker push <ECR_REGISTRY>/acido-secrets:latest- Update Lambda function:
aws lambda update-function-code \
--function-name AcidoSecrets \
--image-uri <ECR_REGISTRY>/acido-secrets:latest- Set environment variables (minimal configuration):
# Generate a random SECRET_KEY for additional encryption
SECRET_KEY=$(openssl rand -hex 32)
aws lambda update-function-configuration \
--function-name AcidoSecrets \
--environment "Variables={
KEY_VAULT_NAME=<your-vault-name>,
AZURE_TENANT_ID=<tenant-id>,
AZURE_CLIENT_ID=<client-id>,
AZURE_CLIENT_SECRET=<client-secret>,
SECRET_KEY=$SECRET_KEY,
CORS_ORIGIN=https://secrets.merabytes.com
}"- Set environment variables (with all optional features):
# Generate a random SECRET_KEY for additional encryption
SECRET_KEY=$(openssl rand -hex 32)
aws lambda update-function-configuration \
--function-name AcidoSecrets \
--environment "Variables={
KEY_VAULT_NAME=<your-vault-name>,
AZURE_TENANT_ID=<tenant-id>,
AZURE_CLIENT_ID=<client-id>,
AZURE_CLIENT_SECRET=<client-secret>,
SECRET_KEY=$SECRET_KEY,
CORS_ORIGIN=<your-cors-origin-url>,
CF_SECRET_KEY=<cloudflare-turnstile-secret-key>
}"The Lambda function supports Cross-Origin Resource Sharing (CORS) to allow web applications to interact with the secrets API. The service now supports multiple allowed origins with dynamic origin matching.
-
Default Origins: If not specified, the service allows requests from:
https://secrets.merabytes.comhttps://app.merabytes.comhttps://local.merabytes.com
-
Custom Origins: Set
CORS_ORIGINenvironment variable to a comma-separated list of allowed origins -
Dynamic Matching: The service inspects the
Originheader in each request and returns it in the response if it's in the allowed list
Example - Single origin:
# Set custom CORS origin (single domain)
aws lambda update-function-configuration \
--function-name AcidoSecrets \
--environment "Variables={...,CORS_ORIGIN=https://myapp.example.com}"Example - Multiple origins:
# Set custom CORS origins (multiple domains, comma-separated)
aws lambda update-function-configuration \
--function-name AcidoSecrets \
--environment "Variables={...,CORS_ORIGIN=https://app1.example.com,https://app2.example.com,https://local.example.com}"In GitHub Actions:
Add CORS_ORIGIN to your repository secrets to configure it during deployment. Use a comma-separated list for multiple origins.
The service responds with the following CORS headers:
Access-Control-Allow-Origin: Dynamically set to match the request origin if it's in the allowed list (defaults to first allowed origin if no match)Access-Control-Allow-Methods:POST, OPTIONSAccess-Control-Allow-Headers:Content-Type
- The service extracts the
Originheader from incoming requests - If the origin is in the allowed list, it's returned in the
Access-Control-Allow-Originresponse header - If the origin is not in the allowed list (or missing), the first allowed origin is used as the default
- This allows multiple frontend applications to interact with the same Lambda function while maintaining security
CloudFlare Turnstile provides bot protection to prevent abuse of the secrets service. It's optional and only activated when the CF_SECRET_KEY environment variable is set.
-
Create a Turnstile Site:
- Go to https://dash.cloudflare.com/
- Navigate to Turnstile
- Create a new site
- Choose "Managed" or "Non-Interactive" mode
- Copy the Site Key and Secret Key
-
Configure Lambda:
- Add
CF_SECRET_KEYto your Lambda environment variables - Deploy the updated configuration
- Add
-
Frontend Integration:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
-
Send Token with Request:
const turnstileToken = document.querySelector('[name="cf-turnstile-response"]').value; fetch(lambdaUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', secret: 'my-secret', turnstile_token: turnstileToken }) });
- When CF_SECRET_KEY is NOT set: Turnstile validation is skipped entirely
- When CF_SECRET_KEY is set: All requests must include a valid
turnstile_token - Invalid or missing tokens return 403 Forbidden
- The service validates tokens with CloudFlare's API
- Remote IP is extracted from Lambda context when available
Note: Remember to include turnstile_token in all requests if CloudFlare Turnstile is enabled.
- One-Time Access: Secrets are automatically deleted after retrieval
- Multi-layer Encryption: Defense-in-depth with two independent encryption layers
- System-level SECRET_KEY encryption (always applied, auto-generated per deployment)
- Optional user-level password encryption (AES-256 with PBKDF2, 100,000 iterations)
- Password Encryption: Optional AES-256 encryption with PBKDF2 (100,000 iterations)
- Bulletproof Encryption Detection: Metadata storage ensures reliable encryption status tracking
- Azure KeyVault: Industry-standard secret storage with access controls
- HTTPS Only: All communication should be over HTTPS
- No Logging: Secrets should never be logged
- Bot Protection: CloudFlare Turnstile prevents automated abuse
- Secure Deletion: Secrets deleted even if decryption fails (prevents brute force)
- Time Limits: Consider adding TTL for secrets in Key Vault (future enhancement)
Lambda timeout:
- Default timeout is 3 seconds, increase if needed for Key Vault operations
Key Vault access denied:
- Verify Service Principal has
Get,Set,Deletepermissions - Check that environment variables are correctly set
Secret not found on first access:
- Wait a few seconds after creation for Key Vault replication
- Verify the UUID is correct
MIT License - See LICENSE file for details
- Xavier Álvarez (xalvarez@merabytes.com)