Photo by Jahanzeb Ahsan on Unsplash
SNS subscriptions pending confirmation show up in ListSubscriptions with PendingConfirmation in place of a subscription ARN. SNS deletes them after 48 hours if nobody confirms (30 days for email subscriptions it suspended for exceeding 10 messages per second), and you can’t delete them yourself. The same list also reveals the subscriptions worth reviewing: plain http endpoints, outside email addresses and cross-account queues.
An SNS topic is only as private as its subscription list. Anyone who can publish to a topic reaches every endpoint subscribed to it, and those endpoints pile up: a webhook someone tested with in 2023, a contractor’s email address, an SQS queue in an account that has since changed hands. None of it shows up in the topic’s access policy.
This example is for engineers cleaning up SNS before an audit. The script lists every subscription in a Region, separates SNS subscriptions pending confirmation from confirmed ones, flags endpoints that deserve a second look, and reads each confirmed subscription’s attributes. It reports by default and can remove confirmed plain-http subscriptions you name. If you’re checking who may publish or subscribe in the first place, start with the example to find public SNS topics and SQS queues; this one looks at who is already subscribed.
Why do SNS subscriptions stay pending confirmation?
SNS requires the endpoint owner to confirm a subscription when the protocol is http, https or email, or when the endpoint is in a different AWS account from the topic. SNS sends a confirmation message; the owner visits the SubscribeURL or calls ConfirmSubscription with the token, which is valid for two days. Until then:
ListSubscriptionsreturns the stringPendingConfirmationinstead of an ARN, so you can’t read attributes or callUnsubscribeon it.- AWS’s documentation on deleting SNS topics and subscriptions is explicit: you can’t delete a subscription that’s pending confirmation, and SNS deletes unconfirmed subscriptions after 48 hours. Email and email-json subscriptions that SNS suspended for exceeding 10 messages per second stay for 30 days.
- An email subscription that suddenly reads pending again was probably suspended for that reason; it needs reconfirming, or alerts stop arriving.
So a pending subscription is usually a symptom: a webhook that can’t handle the confirmation message, a typo in an address, a cross-account queue whose owner never confirmed, or an alert address that received a burst. Find the cause; the entry itself will disappear.
Which confirmed subscriptions are risky?
| Finding | Why it matters |
|---|---|
Plain http endpoint |
Messages cross the internet unencrypted. Move the receiver to https. |
| Email outside your domains | Every message on the topic goes to a mailbox you don’t control. |
| Cross-account SQS, Lambda or Firehose | Another account receives a copy of every message. Fine if intended, but it should be on a list somewhere. |
| Unsubscribe needs no AWS credentials | ConfirmationWasAuthenticated isn’t true, so the confirmation didn’t set AuthenticateOnUnsubscribe. Anyone with the unsubscribe link in a message can remove the subscription, and alerts go quiet. |
| No dead-letter queue | Without a RedrivePolicy, messages SNS can’t deliver after its retries have nowhere to go. |
RawMessageDelivery and FilterPolicy aren’t risks, but the script shows them because they explain why a subscriber receives what it does. The guide to publish an SNS message with AWS SDK v3 in TypeScript covers how message attributes and filter policies interact.
What does the script do?
- Lists subscriptions
paginateListSubscriptionspages through every subscription in the Region, 100 at a time. - Checks the list entryPending status,
httpprotocol, email domain against--email-domains, the account ID in SQS/Lambda/Firehose endpoint ARNs versus the topic’s, and the subscriptionOwner. - Reads attributes of confirmed subscriptions
GetSubscriptionAttributesreturnsConfirmationWasAuthenticated,RedrivePolicy,FilterPolicyandRawMessageDelivery. - Optionally unsubscribes
--apply --arnscallsUnsubscribeonly for confirmed subscriptions with protocolhttp; anything else is refused.
Prerequisites
- Node.js 18 or later,
tsx, and@aws-sdk/client-sns. - Run it once per Region that has topics: SNS subscriptions are Regional.
- Your company’s email domains for
--email-domains, and a list of accounts you expect to receive messages.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListSubscriptions",
"Effect": "Allow",
"Action": "sns:ListSubscriptions",
"Resource": "*"
},
{
"Sid": "ReadSubscriptionAttributes",
"Effect": "Allow",
"Action": "sns:GetSubscriptionAttributes",
"Resource": "arn:aws:sns:*:111122223333:*"
},
{
"Sid": "ApplyOnlyOnOneTopic",
"Effect": "Allow",
"Action": "sns:Unsubscribe",
"Resource": "arn:aws:sns:us-east-1:111122223333:alerts"
}
]
}
IAM authorizes GetSubscriptionAttributes and Unsubscribe against the topic ARN, not the subscription ARN. Leave out the last statement for reports. The IAM policy generator for TypeScript code drafts a starting policy if you change the script.
The script to find SNS subscriptions pending confirmation and risky endpoints
// find-sns-subscriptions-to-review.ts
// Lists every SNS subscription in a Region and flags the ones worth a second look: pending confirmation,
// plain-http endpoints, email addresses outside your domains, SQS/Lambda/Firehose endpoints in other accounts,
// unsubscribe links that work without AWS credentials, and subscriptions without a dead-letter queue.
// Report only, unless you pass --apply with the ARNs of confirmed http:// subscriptions to remove.
// Usage: npx tsx find-sns-subscriptions-to-review.ts [--region us-east-1] [--email-domains example.com,example.org]
// npx tsx find-sns-subscriptions-to-review.ts --apply --arns arn:aws:sns:us-east-1:111122223333:alerts:1a2b...
import {
GetSubscriptionAttributesCommand,
SNSClient,
UnsubscribeCommand,
paginateListSubscriptions,
type Subscription,
} from "@aws-sdk/client-sns";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const list = (name: string): string[] => (flag(name) ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const emailDomains = list("--email-domains").map((d) => d.toLowerCase());
const apply = args.includes("--apply");
const applyArns = list("--arns");
if (apply && applyArns.length === 0) {
console.error("--apply needs --arns with the subscription ARNs to remove");
process.exit(1);
}
const sns = new SNSClient({ region });
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const accountOf = (arn = ""): string => arn.split(":")[4] ?? "";
const isPending = (s: Subscription): boolean => !(s.SubscriptionArn ?? "").startsWith("arn:");
const ARN_PROTOCOLS = new Set(["sqs", "lambda", "firehose"]);
const LINK_PROTOCOLS = new Set(["http", "https", "email", "email-json"]);
async function review(s: Subscription): Promise<string[]> {
const findings: string[] = [];
const protocol = s.Protocol ?? "";
const endpoint = s.Endpoint ?? "";
const topicAccount = accountOf(s.TopicArn);
if (isPending(s)) findings.push("PENDING CONFIRMATION");
if (protocol === "http") findings.push("PLAIN HTTP endpoint");
if (protocol.startsWith("email") && emailDomains.length > 0) {
const domain = endpoint.split("@")[1]?.toLowerCase() ?? "";
if (!emailDomains.includes(domain)) findings.push(`EXTERNAL email domain ${domain}`);
}
if (ARN_PROTOCOLS.has(protocol) && accountOf(endpoint) !== topicAccount) {
findings.push(`CROSS-ACCOUNT ${protocol} endpoint in ${accountOf(endpoint)}`);
}
if (s.Owner && s.Owner !== topicAccount) findings.push(`created by account ${s.Owner}`);
if (isPending(s)) return findings; // pending subscriptions have no ARN to read attributes from
const { Attributes: a = {} } = await sns.send(new GetSubscriptionAttributesCommand({ SubscriptionArn: s.SubscriptionArn }));
if (LINK_PROTOCOLS.has(protocol) && a.ConfirmationWasAuthenticated !== "true") {
findings.push("unsubscribe needs no AWS credentials");
}
if (!a.RedrivePolicy) findings.push("no dead-letter queue");
if (a.FilterPolicy) findings.push(`filter on ${a.FilterPolicyScope ?? "MessageAttributes"}`);
if (a.RawMessageDelivery === "true") findings.push("raw delivery");
return findings;
}
async function applyChanges(subs: Subscription[]): Promise<void> {
for (const arn of applyArns) {
const s = subs.find((x) => x.SubscriptionArn === arn);
if (!s || s.Protocol !== "http") {
// Refuse anything that isn't a confirmed plain-http subscription found in this Region
console.error(`${arn}: skipped, not a confirmed http:// subscription in ${region}`);
process.exitCode = 1;
continue;
}
try {
await sns.send(new UnsubscribeCommand({ SubscriptionArn: arn }));
console.log(`${arn}: unsubscribed ${s.Endpoint}`);
} catch (err) {
console.error(`${arn}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
async function main(): Promise<void> {
const subs: Subscription[] = [];
for await (const page of paginateListSubscriptions({ client: sns }, {})) subs.push(...(page.Subscriptions ?? []));
if (apply) return applyChanges(subs);
const rows: Record<string, string>[] = [];
for (const s of subs) {
let findings: string[];
try {
findings = await review(s);
} catch (err) {
findings = [`error: ${errText(err)}`];
}
rows.push({
Topic: (s.TopicArn ?? "").split(":").pop() ?? "?",
Protocol: s.Protocol ?? "?",
Endpoint: s.Endpoint ?? "?",
Status: isPending(s) ? "pending" : "confirmed",
Findings: findings.join("; ") || "-",
});
}
const flagged = (r: Record<string, string>): boolean => /PENDING|PLAIN|EXTERNAL|CROSS|credentials/.test(r.Findings);
rows.sort((a, b) => Number(flagged(b)) - Number(flagged(a)));
console.table(rows);
const pending = rows.filter((r) => r.Status === "pending").length;
console.log(`${subs.length} subscriptions in ${region}: ${pending} pending confirmation, ${rows.filter(flagged).length} to review.`);
console.log("Report only: nothing was changed.");
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-sns
npm install --save-dev tsx typescript @types/node
AWS_PROFILE=readonly npx tsx find-sns-subscriptions-to-review.ts --region us-east-1 --email-domains example.com
# Remove one confirmed http:// subscription
AWS_PROFILE=messaging-admin npx tsx find-sns-subscriptions-to-review.ts --region us-east-1 \
--apply --arns arn:aws:sns:us-east-1:111122223333:alerts:3f1c2b9e-0d4a-4a57-9a55-2c3e5f6a7b8c
Sample output
┌─────────┬──────────┬──────────┬──────────────────────────────────────────────────────────────┬─────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Topic │ Protocol │ Endpoint │ Status │ Findings │
├─────────┼──────────┼──────────┼──────────────────────────────────────────────────────────────┼─────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 0 │ 'alerts' │ 'email' │ '[email protected]' │ 'pending' │ 'PENDING CONFIRMATION' │
│ 1 │ 'alerts' │ 'http' │ 'http://hooks.example.net/sns' │ 'confirmed' │ 'PLAIN HTTP endpoint; unsubscribe needs no AWS credentials; no dead-letter queue; raw delivery' │
│ 2 │ 'orders' │ 'sqs' │ 'arn:aws:sqs:us-east-1:444455556666:orders-copy' │ 'confirmed' │ 'CROSS-ACCOUNT sqs endpoint in 444455556666; filter on MessageBody' │
│ 3 │ 'alerts' │ 'email' │ '[email protected]' │ 'confirmed' │ 'EXTERNAL email domain gmail.com; unsubscribe needs no AWS credentials; no dead-letter queue' │
│ 4 │ 'orders' │ 'lambda' │ 'arn:aws:lambda:us-east-1:111122223333:function:index-order' │ 'confirmed' │ '-' │
└─────────┴──────────┴──────────┴──────────────────────────────────────────────────────────────┴─────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────┘
5 subscriptions in us-east-1: 1 pending confirmation, 4 to review.
Report only: nothing was changed.
Topics, endpoints and account IDs are illustrative (the output came from a run against mocked SDK clients). The on-call address is pending: either a new subscription nobody clicked, or a suspended one that needs reconfirming before the next incident. The http webhook is the one to move to HTTPS or remove. The cross-account queue in 444455556666 receives every order event; confirm someone still owns that account.
How do you fix what the script finds?
- Pending email or webhook: resend by subscribing again, and fix the receiver so it handles the confirmation message. For suspended email subscriptions, reconfirm and reduce the publish rate to that address.
- Plain
http: subscribe thehttpsURL, confirm it, then remove the old subscription with--apply. - Unauthenticated unsubscribe: resubscribe and confirm with
ConfirmSubscriptionandAuthenticateOnUnsubscribeset totrue; afterwards only the topic owner and subscription owner can unsubscribe, with AWS credentials. - No dead-letter queue: add a
RedrivePolicypointing at an SQS queue, then watch it. The example to find SQS queues without a dead-letter queue covers the queue side, and the one to find SQS queues and SNS topics without encryption checks the data at rest. A subscribed queue that receives messages but never drains them shows up in the check to find stuck SQS queues by approximate age of oldest message. - Unknown cross-account endpoints: trace the account first. The example to find IAM roles trusted by external AWS accounts often turns up the same partner accounts.
Troubleshooting
AuthorizationErroronGetSubscriptionAttributes. The policy’s resource must match the topic ARN. The script prints the error in that row and moves on.- A pending entry is skipped by
--apply. Pending subscriptions have no ARN, so the script can’t match or remove them. Wait for SNS to delete them. - Subscriptions you expected are missing. Check the Region.
ListSubscriptionsreturns the requester’s subscriptions in that Region only. - Lambda endpoint in the same account still flagged. Look at
Owner: a different account created the subscription, which is worth knowing even when the endpoint is yours.
Ask ChatWithCloud instead
For a quick look, ask ChatWithCloud “Which SNS subscriptions in us-east-1 are pending confirmation or use http?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and answers from the output; the guide to list AWS resources with natural language from your terminal shows how that works for other inventories. It can be wrong and runs changes without a confirmation step, so use a read-only profile, as the ChatWithCloud security page recommends, and unsubscribe with the script.
Frequently asked questions
How long do SNS subscriptions stay pending confirmation?
SNS deletes unconfirmed subscriptions after 48 hours. Email and email-json subscriptions that SNS suspended for exceeding 10 messages per second stay pending for 30 days before deletion.
Can I delete an SNS subscription that is pending confirmation?
No. A pending subscription has no ARN to pass to Unsubscribe, and AWS documents that you can’t delete it. Wait for SNS to remove it.
How long is an SNS confirmation token valid?
Two days, according to the Subscribe API reference. After that, subscribe the endpoint again to get a new confirmation message.
Do SQS and Lambda subscriptions need confirmation?
Only when the queue or function is in a different AWS account from the topic. Same-account SQS and Lambda subscriptions don’t need a confirmation step.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud