Security overview
This document is for security reviewers. It covers what BucketDeck protects against, how users are identified, how access is limited, where data goes and how it is encrypted. Everything described here runs in your own AWS account.
Threat model
BucketDeck gives people access to data in S3. What it must prevent:
| Threat | Mitigation |
|---|---|
| A user reaches data no one granted them | Two independent layers: BucketDeck grants checked by the API on every call, and IAM credentials that a per-request session policy limits to what the grants allow (see Permissions) |
| Someone impersonates another user | Federated sign-in; the API trusts only emails the user can't choose, and binds the bootstrap admin to an immutable ID (see Identity) |
| A stolen token is used from elsewhere | One-hour ID tokens, revocable 8-hour refresh tokens, optional IP allowlist checked on every call |
| BucketDeck itself is misused to read or change its own state | The roles users act through explicitly deny BucketDeck's data and artifacts buckets; the table is reachable only by BucketDeck's Lambdas |
| Actions can't be traced | Application audit log, plus SourceIdentity = <user email> on every assumed-role session, so CloudTrail in every target account names the person |
| Another account's role is assumed by a third party ("confused deputy") | Spoke roles trust only the hub's two role ARNs and require an external ID |
| Long-lived credentials leak | Role connections use one-hour STS credentials. Access-key connections are off by default (AccessKeyConnections=Disabled); keys are kept in Secrets Manager and are write-only in the API |
| A shared link outlives the reason it was shared | Managed share links expire, can be revoked, and stop working as soon as the creator loses read; every use is audited |
| A colleague is tricked into approving someone else's AWS sign-in (device-code phishing) | The approval page link carries no code; after approval BucketDeck checks the Identity Center user name and refuses an email that isn't the signed-in user's |
| Data leaves the customer | No vendor service in the data path; transfers run inside the customer's accounts (S3 server-side copy, or streamed through the worker Lambda) |
Out of scope: a malicious administrator of the AWS account (they can change any IAM policy), and the security of the identity provider and of users' devices.
Controls
| Principle | Control |
|---|---|
| Strong identity on every request | Every API route requires a Cognito ID token, validated by the API Gateway JWT authorizer (issuer and audience). The only unauthenticated route serves managed share links. Admin routes check the role on each call. |
| Least privilege | Each component has its own role with resource-scoped permissions (IAM summary). Users act only through roles under the IAM path /bucketdeck/, narrowed per request. |
| Context-aware access | IP allowlist checked on every call (Business and above); outside it, every route except the sign-in check returns 403 ip_not_allowed. |
| Short-lived credentials | STS sessions of at most one hour. Presigned URLs last 5 minutes by default. Links that must live longer are managed share links, which never hand out a long-lived signature. |
| Strong authentication | Your identity provider's MFA applies. Cognito-only users must use TOTP MFA and passwords of at least 14 characters. User-existence errors are suppressed. |
| Hardened portal | Strict CSP, HSTS, X-Frame-Options: DENY, Referrer-Policy: no-referrer, restrictive Permissions-Policy, no source maps (Portal). |
| Safe previews | Office documents are converted server-side to sanitized HTML (no scripts, no external URLs) and shown in a sandboxed iframe; media and PDF are shown from short-lived presigned URLs. |
| Auditability | Application audit log, API access logs, Step Functions error logs, and CloudTrail in every account with the user's SourceIdentity. |
Identity and authorization
The API trusts the Cognito ID token's signature, issuer and audience (checked by API Gateway), and
then decides who the caller is from its claims. Roles, grants, job ownership and the bootstrap admin
are keyed on the email claim, so a user who could pick their own email could take over someone
else's access. The API therefore accepts the email only when it is one the user can't choose;
otherwise it refuses the caller with 403 forbidden on every route.
| How the user signs in | Email trusted when | Why |
|---|---|---|
| SAML: IAM Identity Center, Entra ID, Okta | always | The email is asserted by your directory and signed by the IdP. Map it from an attribute users can't edit (Identity providers). |
| OIDC | email_verified is true | Many OIDC IdPs let users or guests set their email. The stack maps the IdP's verification claim to Cognito's email_verified. |
| Cognito user pool user | email_verified is true | Users are created by administrators and can't change their email. |
The provider is read from the signed token, never from anything the client sends.
Bootstrap admin. BootstrapAdminEmail is always an admin, so it is bound to an immutable
identity: the first trusted sign-in with that email records the caller's Cognito user ID, and from
then on only that user is the bootstrap admin. Sign in as the bootstrap admin straight after
deployment. Resetting the binding is described in the
admin guide.
Permissions, grants and session policies
What a user can do with an object is the intersection of two independent layers.
- The connection's IAM role sets the ceiling for everyone: the local role is limited by the
stack parameters
LocalBucketAccessandLocalAccessLevel; a spoke role by itsAccessLevelandBucketScope. BucketDeck can never do more in an account than its role allows, and the account owner controls that role. - BucketDeck grants decide what each user may do within that ceiling: a subject, a connection,
scopes (a bucket, a bucket prefix or a bucket wildcard) and permissions (
read,write,delete,manage,share). See the admin guide.
Grants are not only checked in code. For every request, the API and the worker call
sts:AssumeRole on the connection's role with:
SourceIdentity= the user's email. It can't be changed for the rest of the session, also in role chains, and it appears in CloudTrail for every S3 call in the target account;- a role session name
bd-<email>, also visible in CloudTrail; - a session policy (for non-admins) that allows only the S3 actions and bucket and prefix ARNs the user's grants allow. Even if the API had a bug in its own checks, AWS would refuse anything outside the grants. If a user's grants are too many to fit the session-policy size limit, the request is refused rather than widened.
The hub roles may assume only roles under the IAM path /bucketdeck/, and spoke roles trust them
only with your external ID.
Access-key connections (only with AccessKeyConnections=Enabled) are the exception: static IAM
user keys can't carry a session policy or SourceIdentity, so for them only BucketDeck's own checks
and the IAM user's policies apply, and CloudTrail shows the IAM user. Prefer role connections.
Personal AWS access (users' own IAM Identity Center permissions or IAM user keys) is not governed by grants: AWS IAM alone decides what the user can do, and CloudTrail records the user's own identity. IAM user keys are exchanged for temporary credentials and never stored.
Jobs are checked against the owner's grants when they are created. The worker then makes the job's
S3 calls with the owner's email as SourceIdentity, so CloudTrail attributes job traffic to the
person who started it.
IAM summary
| Role | Assumed by | Permissions |
|---|---|---|
API role (<prefix>-api-<region>) | Lambda | Its DynamoDB table; the data bucket; sts:AssumeRole, SetSourceIdentity and TagSession on roles under /bucketdeck/; Secrets Manager on <prefix>/connections/*; start and stop the job state machine; read the account's licences in AWS License Manager; KMS only through DynamoDB, S3 and Secrets Manager, or directly with a bucketdeck-* encryption context |
Worker role (<prefix>-worker-<region>) | Lambda | As the API role, but Secrets Manager read only and no Step Functions |
Local access role (/bucketdeck/<prefix>-local-<region>) | API and worker roles | The S3 action set of LocalAccessLevel on the LocalBucketAccess buckets; s3:ListAllMyBuckets; CloudWatch storage metrics; KMS only through S3. Explicit deny of all S3 actions on BucketDeck's data and artifacts buckets |
Spoke role (/bucketdeck/<RoleName>, other accounts) | API and worker roles, with the external ID | S3 ReadOnly, ReadWrite or Full action set on BucketScope; s3:ListAllMyBuckets; optional CloudWatch metrics; KMS only through S3 |
| Job state machine role | Step Functions | Invoke the worker; job files in the data bucket; its own Distributed Map executions; log delivery |
| Portal deployer role | Lambda | Deploy the portal's Amplify app from one file in the artifacts bucket |
| Installer roles | ECS (one-off task) and Lambda | Run the installer task; read the account's licences; pull the image from the AWS Marketplace registry; write the code to the artifacts bucket |
The S3 action sets:
| Set | Actions |
|---|---|
| ReadOnly | ListBucket, ListBucketVersions, ListBucketMultipartUploads, GetBucketLocation, GetBucketVersioning, GetEncryptionConfiguration, GetBucketPublicAccessBlock, GetBucketOwnershipControls, GetBucketObjectLockConfiguration, GetLifecycleConfiguration, GetReplicationConfiguration, GetBucketLogging, GetBucketTagging, GetBucketCORS, GetObject, GetObjectVersion, GetObjectAttributes, GetObjectVersionAttributes, GetObjectTagging, GetObjectVersionTagging, GetObjectRetention, GetObjectLegalHold |
| ReadWrite | ReadOnly plus PutObject, PutObjectTagging, PutObjectAcl, AbortMultipartUpload, ListMultipartUploadParts |
| Full | ReadWrite plus DeleteObject, DeleteObjectVersion, DeleteObjectTagging, PutObjectVersionTagging, RestoreObject, PutBucketCORS |
No role has * resource access except where the AWS API has no resource-level permissions (for
example s3:ListAllMyBuckets, License Manager and CloudWatch metrics reads), and KMS limited by
kms:ViaService.
Share links
A presigned S3 URL can never outlive the credentials that signed it (at most one hour). Links that must last longer are managed share links (Team and above):
- Only users with the
sharepermission on the object can create them, and only up to the maximum lifetime an admin sets (7 days by default). - The link carries a random, unguessable secret. The table stores only its SHA-256 hash, so someone who reads the table can't rebuild a working link.
- On every use, the API checks the secret, the expiry and the plan, rebuilds the creator's current
permissions, and then redirects to a presigned URL valid for 60 seconds, signed with the
creator's scoped credentials and
SourceIdentity. A failed check returns a plain error without saying which check failed. - A link stops working when it expires, when it is revoked, or when its creator loses
readon the object. BucketDeck keeps no list of users, so removing a user from the identity provider alone does not stop their links: remove their grants and role assignments too, or revoke the links. - Each use is counted and audited with the source IP; creation and revocation are audited too.
A share link is a bearer token: anyone who has it can download the object until the link stops working. Share only what may leave your organization, and prefer short lifetimes.
Data flows
| Flow | Path | Protection |
|---|---|---|
| Sign-in | Browser ⇄ Cognito managed login ⇄ your IdP | TLS; SAML signatures or OIDC; authorization code flow with PKCE |
| API | Browser → API Gateway → Lambda | TLS 1.2+; JWT on every call except share links; CORS limited to the portal origin; throttled |
| S3 calls | Lambda → STS → S3 in any connected account | TLS; per-request, per-user scoped STS credentials with SourceIdentity |
| Uploads, downloads, media previews | Browser ⇄ S3 directly | TLS; presigned URLs signed with the user's scoped credentials, valid for minutes |
| Share links | Recipient → API Gateway → redirect → S3 | TLS; token checked against a stored hash; redirect to a 60-second presigned URL |
| Transfers | Worker Lambda → S3 (server-side copy, or read and write when the accounts can't see each other) | TLS; within your accounts |
| Job state | Lambda ⇄ DynamoDB and the data bucket | TLS; encrypted at rest |
| Licence | Installer, API, worker → AWS License Manager in your account | TLS. A read of the account's licences for the product |
| Code install | AWS Marketplace container registry → installer task → artifacts bucket | Image pulled by digest; every file checked against a SHA-256 manifest; written to a private, TLS-only bucket |
What stays in your account
Everything: identities, connections and keys, grants, listings and previews, job plans and reports, index snapshots and the audit log. Cloud Certainty operates no service in the data path, and the stack never contacts the vendor at runtime. The AWS Marketplace container registry and the vendor's public bucket in us-east-1 (templates and the release manifest) are read-only distribution points used at install and upgrade. Links to cloudcertainty.com in the portal are plain links, followed only when someone clicks them.
Encryption
- In transit. TLS for every AWS API and the portal (HTTPS only, with HSTS). The data and artifacts buckets deny non-TLS requests.
- At rest. Everything BucketDeck stores is encrypted with one customer managed KMS key: the key
the stack creates (automatic rotation, retained with the table), or your own key (
KmsKeyArn).- DynamoDB table: SSE-KMS with that key, plus item-level encryption (Data at rest).
- Data and artifacts buckets: SSE-KMS with an S3 Bucket Key.
- Access-key secrets (Secrets Manager) and CloudWatch Logs: the same key.
- Your own buckets keep their own encryption settings. Copies use the destination's default encryption unless the job sets one.
Data at rest
Table encryption at rest alone is transparent: anyone allowed to read the table reads plaintext. So BucketDeck seals every item itself before writing it, and the table refuses changes from anyone else:
| Layer | What it gives |
|---|---|
| Item encryption | All item content (connections, grants, roles, settings, jobs, share links, listings, Lens summaries, audit events, personal AWS sessions) is encrypted with AES-256-GCM under a per-table data key. Emails, connection IDs, bucket names and prefixes in keys are replaced by keyed hashes. With full DynamoDB access, a console, Scan or table export shows only ciphertext and hashes. |
| Integrity | Each ciphertext is bound to its item's key and expiry. An item changed, copied, moved or extended outside BucketDeck no longer opens, and BucketDeck refuses it. Not covered: putting back an older ciphertext of the same item, for example from a backup. The table's resource policy is the control against that; restore table backups only into a new table. |
| Key policy | The data key is wrapped by the KMS key. Only BucketDeck's API and worker roles may call KMS directly, and only with a bucketdeck-* encryption context. The created key's policy gives IAM administrators key administration but no cryptographic use, so IAM permissions alone (even AdministratorAccess) never decrypt the data. The data bucket (job reports, Lens snapshots, search index shards) and the access-key secrets are not item-encrypted: keep s3:GetObject on the data bucket and secretsmanager:GetSecretValue on <prefix>/connections/* to administrators. |
| Table resource policy | Denies writes to every principal except the API and worker roles, whatever their IAM policies say, and denies DeleteTable to everyone. Deletion protection is on. |
What remains, and how to cover it:
- Atomic counters (job progress, share use counts) stay outside the ciphertext so DynamoDB can increment them. The resource policy protects them.
- Whoever can change the key policy (
kms:PutKeyPolicy) or the table's resource policy (dynamodb:PutResourcePolicy) could undo these controls. Both are visible in CloudTrail. Restrict them to your break-glass role with an SCP or permission boundary, and alert on them. - A restored or exported copy of the table stays encrypted, and BucketDeck can use a restored table as long as the same KMS key is available. Losing the key makes the data unreadable; BucketDeck then refuses to start rather than silently create a new key.
Portal hardening
Amplify Hosting serves the portal with these headers:
- A strict
Content-Security-Policy: no inline or third-party script,frame-ancestors 'none', and AWS endpoints allowed only for sign-in and for presigned previews, downloads and uploads. Strict-Transport-Security,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,X-Frame-Options: DENY, and a Permissions-Policy that turns off camera, microphone, geolocation, payment and USB.
Tokens are kept in session storage, never in cookies; refresh tokens last 8 hours and can be revoked.
Audit
Every administrative change, every connection and grant change, every job (start, cancel, retry,
result), every share link (created, opened, revoked), upload, delete, rename and metadata change is
written to the audit log with the actor, source IP and target. Admins read it in
Administration → Audit. Events are kept for the stack's AuditRetentionDays, capped by the plan:
30 days on Starter, 90 on Team, 365 on Business and 3,650 on Enterprise. For each AWS call,
CloudTrail in the account that owns the bucket shows the assumed-role session with sourceIdentity
set to the user's email. Forward CloudTrail (with S3 data events if you need object-level detail)
and the log groups to your SIEM.
Licensing
BucketDeck reads its AWS Marketplace licence from AWS License Manager in your account: at install,
in the API and in the worker. Nothing about your usage or data is sent anywhere. Without a valid
licence the installer stops, the API answers 402 licence_required and the worker starts no new
jobs. If License Manager is unreachable, or a licence that was active disappears (which bridges an
upgrade between plans), BucketDeck keeps working for up to 72 hours from the last successful check.
Your data stays in place either way.
Shared responsibility
- Keep
BootstrapAdminEmailon a protected identity and enforce MFA at your IdP. Grant Admin to a second person or group. - Set spoke roles to the lowest
AccessLeveland narrowestBucketScopethat works; grants can narrow further but never widen them. - Prefer role connections; leave
AccessKeyConnections=Disabledunless you need keys, and rotate keys you store. - On Business and above, set the IP allowlist to your corporate egress ranges if users should only connect from managed networks.
- Enable CloudTrail S3 data events where you need object-level audit, and forward logs to your SIEM.
Something unclear or missing? Email [email protected].