Tenant storage (BYOB)
Status: Generally available since 2026-04-22.
The Storage section lets a tenant connect its own object storage and host applicant documents, KYB materials, and selfies there instead of in KnowaKYC's shared storage. Supported providers are S3, S3-compatible buckets, GCS, and Azure Blob containers. This capability is called BYOB — bring-your-own-bucket.
By default, every tenant uses KnowaKYC's shared storage (mode shared). Connecting a tenant-owned bucket is an opt-in feature, available without data migration and without downtime.
When to use BYOB
Connecting your own bucket makes sense in the following cases:
- Regulatory / data-residency compliance. User documents must be stored in a specific jurisdiction or in infrastructure controlled by the tenant.
- Corporate security policies. Data access must be limited to the tenant's security team. The tenant brings its own KMS keys, its own IAM, and its own audit trail (CloudTrail / Cloud Audit Logs / Storage Analytics).
- Cost control. The tenant pays for storage directly to its cloud provider.
- Greater lifecycle control. Self-managed lifecycle policies (archival → Glacier / Coldline / Cool tier, retention).
If none of these are critical, keep shared storage — it requires zero setup and is operated by the KnowaKYC team.
Supported providers
| Provider | provider value | Authentication | Notes |
|---|---|---|---|
| AWS S3 | aws_s3 | STS AssumeRole + ExternalID | Cross-account role; KnowaKYC does not store long-term AWS keys |
| S3-compatible | s3_compat | Static AccessKey + SecretKey | Cloudflare R2, Backblaze B2, MinIO, Wasabi, IBM Cloud Object Storage |
| Google Cloud Storage | gcs | Service Account JSON | base64-encoded Service Account key |
| Azure Blob Storage | azure_blob | Account Key, ConnectionString, or SAS Token | Any of the three |
For AWS S3, the AssumeRole + ExternalID pattern is used — AWS's recommended way to grant a third-party service limited access to account resources without long-term credentials. The ExternalID protects against the "confused deputy" attack.
Creating a configuration
Through the admin panel
- Go to Settings → Storage.
- Click "Connect own bucket".
- Pick the provider type and fill in the parameters (see below).
- Save the draft — the configuration is created in
pendingstatus and is not yet used. - Run validation (see "Configuration validation" below).
- After validation succeeds — activate the configuration.
Through the API
Example for AWS S3
POST /v1/admin/storage/configs HTTP/1.1
Authorization: Bearer <admin_jwt>
Content-Type: application/json
{
"provider": "aws_s3",
"bucket": "my-tenant-onekyc",
"region": "eu-central-1",
"aws_role_arn": "arn:aws:iam::123456789012:role/KnowaKYCStorage",
"sse_mode": "sse_kms",
"sse_kms_key_id": "arn:aws:kms:eu-central-1:123456789012:key/abcd-..."
}The response returns the configuration object in pending status and a generated aws_external_id. Add this ExternalID to the trust-policy condition of the IAM role on the tenant side (see below).
Example for an S3-compatible store (Cloudflare R2)
POST /v1/admin/storage/configs HTTP/1.1
{
"provider": "s3_compat",
"bucket": "tenant-onekyc-r2",
"region": "auto",
"endpoint": "https://<account-id>.r2.cloudflarestorage.com",
"use_path_style": true,
"access_key_id": "<R2 access key id>",
"secret_key": "<R2 secret>"
}Example for Google Cloud Storage
POST /v1/admin/storage/configs HTTP/1.1
{
"provider": "gcs",
"bucket": "tenant-onekyc-gcs",
"service_account_json": "<base64-encoded SA JSON key file>"
}Example for Azure Blob
POST /v1/admin/storage/configs HTTP/1.1
{
"provider": "azure_blob",
"bucket": "onekyc-data",
"endpoint": "<storage account name>",
"azure_account_key": "<account key>"
}The fields
secret_key,service_account_json,azure_account_key,azure_connection_string,azure_sas_tokenare write-only — they are encrypted and never returned by any endpoint.
Required IAM permissions (AWS)
Trust policy of the IAM role (on the tenant side):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<onekyc account id>:role/onekyc-byob-resolver" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "<aws_external_id from Create response>" }
}
}]
}Permission policy of the IAM role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:GetObjectAcl",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::<bucket>/*"
}, {
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::<bucket>"
}]
}If SSE-KMS mode is enabled (sse_mode = "sse_kms"), a KMS-key policy is also required:
{
"Effect": "Allow",
"Action": [
"kms:Encrypt",
"kms:Decrypt",
"kms:GenerateDataKey"
],
"Resource": "<sse_kms_key_id>"
}Configuration validation (validation probe)
Before activation the configuration goes through 14 automated checks (10 baseline + 4 production-readiness):
POST /v1/admin/storage/configs/{id}/validate| # | Check | What it verifies |
|---|---|---|
| 1 | head_bucket | The bucket exists and s3:ListBucket is granted |
| 2 | put_probe | A probe object is uploaded (__probe__/health-*.txt) |
| 3 | get_probe | The same object is read back |
| 4 | delete_probe | The probe object is deleted |
| 5 | presign_get | A signed GET URL is generated (used to serve files to the frontend) |
| 6 | presign_post | A signed POST URL is generated (used for direct upload from the applicant's device) |
| 7 | list_objects | Listing objects by prefix |
| 8 | category_dispatch | Correct split by category (documents / selfies / ...) |
| 9 | health_quick | Baseline provider health check |
| 10 | probe_cleanup | All created probe objects are removed |
| 11 | public_access | Anonymous read must be denied (Block Public Access) |
| 12 | clock_skew | Server / client clock skew is within 60 s |
| 13 | multipart_upload | Multipart upload of large files works correctly |
| 14 | copy_object | Server-side copy is supported (required for shared → tenant migration) |
A 15th probe (sse_enforcement) runs when sse_mode = "sse_kms" and suggests a bucket policy that denies uploads without encryption.
The response contains per-probe details:
{
"data": {
"report": {
"results": [
{ "name": "head_bucket", "passed": true, "duration_ns": 145000000 },
{ "name": "put_probe", "passed": false,
"error": "AccessDenied",
"action_hint": "Grant s3:PutObject on bucket prefix" }
],
"all_passed": false
},
"all_passed": false
}
}For each failed check an action_hint is returned — a concrete suggestion ("add s3:PutObject", "remove Public Access"). Validation returns 200 OK even when some probes fail (with all_passed=false) — this lets the UI show a checklist without parsing the error envelope.
Activation
After validation succeeds, the configuration is moved to the active state:
POST /v1/admin/storage/configs/{id}/activate
X-Confirm-Tenant: <tenant UUID>The X-Confirm-Tenant header is required — it guards against accidental activations. Its value must match the current tenant's UUID.
After activation:
- Every new upload (documents, selfies, etc.) goes straight into the connected tenant bucket.
- Historical data in shared storage is copied in the background.
The action is recorded in the audit log (tenant.updated + action=storage_config_activated).
Historical data migration
Activation switches new uploads to the connected bucket immediately. Files that were uploaded earlier remain available while KnowaKYC copies them to the tenant bucket.
Parameters
- Rate limit: 10 MB/s per tenant by default.
- Integrity check: copied objects are verified after upload.
- Grace period: 30 days between migration completion and the deletion of copies from shared storage.
Migration job states
| Status | Description |
|---|---|
pending | Job created, waiting to start |
running | Copying in progress |
paused | Paused (only via super-admin) |
completed | All objects copied successfully |
failed | Retry limit reached; see last_error |
cancelled | The tenant initiated Disable during migration |
Migration status request
GET /v1/admin/storage/migration/currentThe response contains total_objects / copied_objects / failed_objects counters, byte progress, started_at, completed_at, and the last error.
Health monitoring
GET /v1/admin/storage/healthReturns the latest storage health snapshot:
| Field | Value |
|---|---|
status | healthy / degraded / unhealthy |
last_check_at | Time of the most recent check |
last_success_at | Time of the most recent successful check |
consecutive_failures | Number of consecutive failed checks |
last_error / last_error_code | The latest error |
alert_sent_at | When a degradation notification was sent |
Health checks run automatically in the background (a lightweight probe). After several consecutive failures, the tenant administrator receives an email notification.
Credentials rotation
If the keys are compromised or scheduled rotation is due:
POST /v1/admin/storage/configs/{id}/rotate
Content-Type: application/json
{
"access_key_id": "<new AccessKey>",
"secret_key": "<new SecretKey>"
}For AWS S3, aws_role_arn is typically rotated and the ExternalID is regenerated; for GCS — a new service_account_json; for Azure — a new Account Key / SAS Token.
After rotation the status resets to pending — you have to run Validate again, and only after a successful validation does the configuration become active again (Activate).
Disable
If the tenant wants to return to shared storage:
POST /v1/admin/storage/disable
X-Confirm-Tenant: <tenant UUID>
Content-Type: application/json
{
"reason": "Reason description (minimum 10 characters)"
}Effect:
- The configuration is moved to
disabled. - Every new upload goes back into KnowaKYC's shared storage.
- An unfinished migration is cancelled (status
cancelled). - Files already written to the tenant bucket are NOT deleted — the tenant retains full control over its bucket and can delete them on its own.
- Those files remain available until the configuration is deleted.
The reason field is required (minimum 10 characters) — it is recorded in the audit log for incident analysis.
To connect another bucket later, you have to create a new configuration from scratch.
Deleting a configuration
DELETE /v1/admin/storage/configs/{id}Only configurations in pending, validated, or failed status can be deleted. An active configuration must first be moved to disabled through the /disable endpoint.
Credentials encryption
All secrets (secret_key, service_account_json, Azure keys) are stored in the database in encrypted form:
- Algorithm — AES-256-GCM (envelope encryption).
- The DEK (Data Encryption Key) is derived from the KnowaKYC master key through HKDF-SHA256, with the tenant UUID as salt and a version-tagged info string.
- The master key is stored in the
STORAGE_MASTER_KEYenvironment variable (it does not overlap withENCRYPTION_KEY). - When the encryption format changes, old ciphertexts can still be decrypted through
credentials_version, while new ones are encrypted with the current version.
Secrets cannot be read out through the API — even KnowaKYC super-admins do not have an interface for extraction. If they are lost, the only recovery path is rotation (/rotate) with new credentials.
FAQ
How much does BYOB cost?
On the KnowaKYC side — free (the option is available on every plan). Storage and traffic costs are paid by the tenant directly to its cloud provider (AWS, GCP, Cloudflare, etc.).
Will upload latency increase?
Marginally. KnowaKYC uses presigned URLs — the applicant's browser uploads the file directly into the tenant bucket, bypassing the KnowaKYC API. Latency is determined by the chosen bucket region (pick the region closest to the majority of users).
What happens if AWS / KMS is unavailable?
Uploads during that period return a 5xx error; KnowaKYC does not "fall back" to shared automatically (that would break the compliance invariant). For long outages, the tenant administrator can run Disable — and writes go back into shared storage.
The health check records degradation and sends an email notification after several consecutive failures.
Can I have two active configurations at once?
No. A tenant can have only one active configuration at a time. To switch to another bucket, the current active configuration must first be Disabled, then a new one created.
Diagnostics
| Check | Typical failure cause | Action |
|---|---|---|
head_bucket | Bucket does not exist / no s3:ListBucket | Verify the bucket name; add the permission |
put_probe | No s3:PutObject | Check the permission policy |
get_probe / delete_probe | No s3:GetObject / s3:DeleteObject | Add the permissions |
presign_get | Endpoint is misconfigured | Verify endpoint for S3-compat |
public_access | The bucket responds to anonymous requests | Enable Block Public Access (AWS) / publicAccessPrevention=enforced (GCS) / allowBlobPublicAccess=false (Azure) |
clock_skew | Clock skew exceeds 60 s | Configure NTP on the source server |
multipart_upload | Provider does not support multipart, or no permissions for parts | Verify multipart support with the provider |
copy_object | No s3:CopyObject, or cross-bucket copy is blocked | Add s3:GetObject + s3:PutObject on source and target buckets |
All errors are also recorded in the audit log (storage_config_validated / storage_config_activated / storage_config_disabled).
Troubleshooting
Progress shows 0 / 0 with non-zero error count
If you still see 0 / 0 on a fresh activation, the underlying file inventory is empty — there are no historical files in shared storage to migrate, only new uploads will appear. This is the expected state for a new tenant.
Status Completed but the row reports failed objects
If some files fail to copy, the job moves to failed. KnowaKYC retries it automatically, and the panel exposes a Retry migration button for manual immediate retry.
If the job stays failed after several attempts, inspect the last_error text on the panel. The most common causes:
signature does not match— credentials contain whitespace, orregiondoes not match the endpoint URL segment. Rotate credentials, paste them WITHOUT trailing newlines.permission denied— bucket policy deniess3:PutObjector the IAM role lacks an inline policy. Re-check the policy from the wizard.bucket not found— the bucket was deleted or renamed out of band.
Health code reference
The health panel shows a short error code and a localised hint. The full mapping:
| Code | Meaning | Action |
|---|---|---|
CONFIG_NOT_FOUND | Storage config row missing in DB | Re-run Activate |
CREDENTIALS_INVALID | Driver rejected the credentials | Rotate with fresh keys |
EXTERNAL_ID_MISMATCH | AWS AssumeRole external ID differs | Re-paste the External ID into the IAM trust policy |
TENANT_STORAGE_UNAVAILABLE | Provider transiently unreachable | Wait for next probe; if persistent — check with provider |
PERMISSION_DENIED | Bucket policy blocks the operation | Adjust the bucket / IAM policy |
BUCKET_NOT_FOUND | Bucket deleted out of band | Re-create the bucket OR Disable + new config |
SIGNATURE_MISMATCH | Region drift, or whitespace in keys | Rotate credentials, fix the region field |
HEALTH_CHECK_FAILED | Generic catch-all | See last_error for details |
Path-style addressing
S3-compatible providers differ in their URL style. Some buckets use path-style URLs (host/bucket/key); others use virtual-hosted URLs (bucket.host/key).
KnowaKYC stores the selected value and uses it as-is when creating the storage provider. The next Validate step probes the bucket with that exact mode; if the provider rejects the addressing style, go back and switch Use path-style, then validate again.
See also: Audit log, Tenant settings.