Skip to content

Security

We make you two promises:

  1. Your contacts and history live in your AWS account, and our queues hold IDs only. See Stored only in your account, We don’t keep what we process and Leave with everything.
  2. You can switch us off right away, and every call we make shows in your CloudTrail. See Access you control, Instant off switch and Every call is visible.

And one client can never reach another’s data: see One client can’t reach another.

Each claim below gives the control that enforces it, the tests that check it on every release, and how you can check it yourself.

The claim. Contacts, consent records, send history and events are stored in your DynamoDB tables and S3 buckets. Nothing about your contacts is stored in our account: not in databases, logs or backups. Our work queues hold IDs only.

Control. Every store of your data is a resource in your stack: the two tables, the three buckets and the SSM parameters for import credentials. Our account has three work queues shared by all clients, and each message is IDs only: your account, a campaign, a batch number or an event. Their schemas allow nothing else. SES events reach our event bus whole, because EventBridge can’t trim an event sent to another account, but our rules on the bus keep only IDs, timestamps and flags before anything is queued, and they have no dead-letter queue, so a full event is never stored. The open’s IP address and the clicked URL never reach our queues. Our registry’s schema has no contact fields, and a CI check fails on any new attribute that isn’t on its allowlist.

Tested by S-11 (every item in our registry has only allowlisted attributes, and no addresses) and S-12 (after full flows for two clients, every store in our account is scanned for test addresses and contact data: DynamoDB, S3, the event bus and the work queues including dead-letter queues, Secrets Manager, schedule payloads and logs).

How to check. Compare your stack’s resources with What the template creates, and with our list of what’s stored where.

Terminal window
aws cloudformation list-stack-resources --stack-name rhino-relay \
--query 'StackResourceSummaries[].[LogicalResourceId,ResourceType]' --output table

The claim. Our functions read your contacts to send your campaigns, write your events back and import your lists, in memory only. None of it is written anywhere on our side.

Control. Logs pass through an allowlist: only account, campaign and request IDs and counts are logged, never an address, contact ID or message ID, and a CI rule bans logging request or response objects. Tracing is off. Error messages never repeat the values you sent. No endpoint takes contact data in a URL. CloudFront logging is off for the API and tracking domains, and AWS WAF logs only blocked requests, with paths, query strings and headers redacted. The console’s request log keeps the method, path template, status and timing of each API call, never a body or an address.

Tested by S-08 (after the full test run, no log group holds an address, contact ID or message ID), S-13 (CloudFront and WAF logging, and every URL pattern) and U-LOG (only allowlisted keys are ever logged).

How to check. Errors never echo what you send:

Terminal window
# Expect 422 validation_failed
curl -X POST "$BASE/contacts" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"email":"not-an-address","consent":{"source":"signup-form","at":"2026-09-01T10:00:00Z","method":"single_opt_in"}}'

The response says which field is wrong, but not what you put in it.

The claim. You grant our access in your own AWS account: one IAM role, and EventBridge rules that forward your SES events and upload notices to us. You can read every grant, and you can switch it off at any time.

Control. The role rhino-relay-access-<TenantId> trusts our account only for our one service role in your Region (an aws:PrincipalArn condition), and only with your external ID. Its permissions cover what your stack creates for us, and all of SES in your account. It can’t change the stack, its own or the template’s other IAM roles, or your CloudTrail trail. The forwarding rules can send only to our one event bus in your Region. The full list is in What the template creates.

Tested by S-03 (the role trusts only our service role, with the external ID required) and S-05 (* resources only for SES and metrics; every other permission names this stack’s own resources).

How to check. Read the role’s trust and permission policies, and the forwarding rules, in your account:

Terminal window
aws iam get-role --role-name rhino-relay-access-<TenantId> --query Role.AssumeRolePolicyDocument
aws iam get-role-policy --role-name rhino-relay-access-<TenantId> --policy-name rhino-relay-access
aws events list-rules --name-prefix rhino-relay

The claim. Every session our service opens in your account appears in your CloudTrail, labelled with the part of our service that opened it. Turn on data events to see every individual read and write.

Control. All our functions share one execution role, and every session it opens through your role sets a SourceIdentity naming the function, such as sender or api. Your role requires the permission to set it. Every call we make in your account goes through that role, so it’s recorded against it. The template can create a trail for you, with data events for your tables and buckets if you want them (CreateTrail and DataEvents).

Tested by T-08 (two clients’ campaigns run at once, and each client’s CloudTrail shows only its own sessions) and T-03 (interleaved calls for two clients on warm functions each use their own client’s credentials, checked through SourceIdentity).

How to check. In CloudTrail, filter by the role: each AssumeRole event names the calling function in sourceIdentity. With data events on, every read and write is listed too.

Terminal window
aws cloudtrail lookup-events --max-results 20 \
--lookup-attributes AttributeKey=ResourceName,AttributeValue=arn:aws:iam::<your account>:role/rhino-relay-access-<TenantId>

The claim. Nothing one client sends can read or change another client’s data.

Control. Each request’s account is fixed at the edge, from the API key or the AWS account that signed it, before any handler runs; IDs in paths and bodies can never pick a different one. Credentials are per account: they’re cached by account and function, and our data code has no way to name another account. On our shared queues, only our own functions write send and job messages. SES events and upload notices are tied to a client by the AWS account they came from, which EventBridge sets and a client can’t change, never by anything inside the event. Stack outputs must be in your registered account and Region and belong to your stack. The sender’s SES client is built from your role’s session only, so your campaigns can only ever send through your SES. If one client’s messages flood a queue, only that client’s messages are held back.

Tested by T-01 to T-09: every API endpoint, queue message and scheduled step is run for one test client with another test client’s identifiers, including SES events carrying the other’s tags, and must fail without touching the other’s data. T-08 also runs campaigns for both at once and checks each one’s SES sent only its own. F-26 floods one client’s queues and checks the other is unaffected.

How to check. Ask support for the T-series results of the release you’re on.

The claim. You can switch us off at any time. It ends right away.

Control. Disconnect=true removes every grant to our account in one stack update: the role’s trust in our account, the role’s permission policy, and the forwarding rules. From then on we can’t open a new session in your account. A session we opened before, which lasts at most an hour, loses its permissions once IAM applies the deleted policy, usually within minutes. To end those at once as well, also choose Revoke active sessions on the role in IAM. Deleting the stack removes every grant too, and your tables and buckets are kept either way. See Disconnecting.

Tested by E-ONB-03 (with Disconnect set, and separately with the stack deleted, the account shows as disconnected within 5 minutes, sending stops, and the unsubscribe page shows its fallback).

How to check. Set Disconnect to true and update the stack. Then /health shows tenantState disconnected:

Terminal window
curl "$BASE/health" -H "Authorization: Bearer $KEY"

The claim. Your data is already in your account, so you can leave whenever you like and keep all of it.

Control. Every table, bucket and the event archive is in your stack, and is kept if you delete it. The export API writes your contacts, send records and events as files to your own exports bucket.

Tested by F-20 (after a contract ends and the grace period is over, unsubscribe, erasure and export keep working).

How to check. Start an export and download it. See Erasure and export.

Terminal window
curl -X POST "$BASE/exports" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"kind":"contacts"}'

Email support@rhinorelay.com with “Security” in the subject. We reply within 1 business day. See our incident process.