Scientyfic World

Real Time Image Moderation Using n8n and AWS Rekognition

User-uploaded images are a risk: unmoderated nudity, violence or hate symbols can harm users, damage your brand and create legal exposure. This guide builds a near-real-time pipeline that checks every...

Share:

Get an AI summary of this article

Real Time Image Moderation Using n8n and AWS Rekognition blog banner image

User-uploaded images are a risk: unmoderated nudity, violence or hate symbols can harm users, damage your brand and create legal exposure. This guide builds a near-real-time pipeline that checks every image uploaded to an Amazon S3 bucket with Amazon Rekognition, automatically approves clean images, holds suspect ones in a quarantine area, and alerts a moderator in Slack. n8n connects the pieces.

Updated October 2026: I rebuilt this tutorial because the 2025 version had several problems. It mentioned an “AWS SQS Trigger” node, which n8n does not have; it skipped the SNS topic access policy that S3 needs before it can publish events; it described the SNS payload with the wrong structure (S3 events arrive as a JSON string inside the Message field); it downloaded each image into n8n when the Rekognition node can read straight from S3; it wrapped nodes in “Error Trigger” nodes, which is not how n8n error handling works; and it used a fixed 70% confidence threshold across all categories with no human-review step. This version fixes those, uses Rekognition’s current label taxonomy, and includes a policy script I tested with sample data. I checked the workflow’s node parameters against the n8n 2.41.4 node packages and the AWS behaviour against AWS documentation, but I did not run the whole pipeline in a live AWS account for this update, so test in a throwaway bucket first.

Read This First: What Automated Moderation Can and Cannot Do

  • Rekognition is a filter, not an authority. AWS’s own documentation says the moderation APIs are not an exhaustive filter of inappropriate or offensive content, and that they do not detect whether an image includes illegal content, such as child sexual abuse material (CSAM). If your platform accepts uploads from the public, you may have legal obligations around illegal content (for example, reporting duties) that this pipeline does not meet. Get legal advice and look at specialised tools for that problem.
  • Errors go both ways. The model will flag some harmless images (false positives) and miss some harmful ones (false negatives). Lowering the confidence threshold catches more but flags more innocent images. That is why this design has a human review path rather than only “block or allow”.
  • Privacy. You are sending user images to a cloud service. Make sure your privacy policy covers it, and decide how long quarantined images are kept.
  • Policy is yours. What counts as unacceptable depends on your product. AWS’s documentation puts it plainly: you determine the suitability of content for your application.

The Architecture

  1. A user uploads an image to uploads/ in an S3 bucket.
  2. S3 publishes an object created event to an SNS topic.
  3. n8n’s AWS SNS Trigger receives the notification (n8n subscribes itself to the topic over HTTPS, so n8n must be reachable from the internet).
  4. A Code node unpacks the S3 event. The Rekognition node analyses the image directly from S3.
  5. A Code node applies your policy to the labels and returns approve, review or reject.
  6. Approved images are copied to approved/. Review and reject images are copied to quarantine/ and a Slack alert goes to moderators. The original in uploads/ is then deleted.

Why SNS and not SQS? n8n has an SNS trigger node but no SQS trigger node, so SNS is the way to get S3 events into n8n without extra infrastructure. If you outgrow it (for example, you need buffering for bursts), the usual next step is S3 to SQS to a small worker, or EventBridge, with n8n called by a webhook.

Prerequisites

  • An AWS account and permission to create an S3 bucket, an SNS topic and an IAM user. Use one region throughout (the examples use us-east-1). The S3 bucket and SNS topic must be in the same region.
  • A running n8n instance reachable over HTTPS. See deploying n8n on a Google Cloud VM or choosing a host.
  • A Slack bot credential for alerts (see the Slack chatbot guide for creating one), or swap in email.
  • The AWS CLI (version 2) if you want to follow the commands. I did not have it installed to run them for this update, so they come from the AWS documentation; the console works equally well.

Step 1: The Bucket

Create a private bucket. Bucket names are global, so use your own. Outside us-east-1, you must also pass --create-bucket-configuration LocationConstraint=YOUR_REGION:

aws s3api create-bucket --bucket my-app-image-uploads --region us-east-1

aws s3api put-public-access-block --bucket my-app-image-uploads \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

New S3 buckets are encrypted at rest by default, and blocking public access matters: your application should serve approved images through a CDN or signed URLs, not by making the bucket public. The uploads/, approved/ and quarantine/ prefixes are just folders in the same bucket. Your app should write new uploads only to uploads/ and read only from approved/.

Step 2: The SNS Topic, With the Policy S3 Needs

Create a topic, then attach an access policy that lets S3 publish to it from your bucket. Without this policy, S3 can’t send events and the notification setup in the next step fails. This follows the example in the S3 notification walkthrough; replace the account ID, topic ARN and bucket name with yours:

aws sns create-topic --name image-upload-events --region us-east-1
# note the TopicArn it prints, then put it into topic-policy.json below
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3ToPublish",
      "Effect": "Allow",
      "Principal": { "Service": "s3.amazonaws.com" },
      "Action": "SNS:Publish",
      "Resource": "arn:aws:sns:us-east-1:111122223333:image-upload-events",
      "Condition": {
        "ArnLike": { "aws:SourceArn": "arn:aws:s3:*:*:my-app-image-uploads" },
        "StringEquals": { "aws:SourceAccount": "111122223333" }
      }
    }
  ]
}
aws sns set-topic-attributes --region us-east-1 \
  --topic-arn arn:aws:sns:us-east-1:111122223333:image-upload-events \
  --attribute-name Policy --attribute-value file://topic-policy.json

Step 3: Tell S3 to Notify the Topic

Save this as notification.json. The prefix filter makes S3 send events only for uploads/, which matters because the workflow writes into approved/ and quarantine/ in the same bucket: without a filter, your own copies would trigger more events in a loop.

{
  "TopicConfigurations": [
    {
      "TopicArn": "arn:aws:sns:us-east-1:111122223333:image-upload-events",
      "Events": ["s3:ObjectCreated:*"],
      "Filter": { "Key": { "FilterRules": [ { "Name": "prefix", "Value": "uploads/" } ] } }
    }
  ]
}
aws s3api put-bucket-notification-configuration --bucket my-app-image-uploads \
  --notification-configuration file://notification.json

S3 checks that it can publish to the topic when you save this, and sends a test event, which is why the Code node below ignores messages that have no Records.

Step 4: An IAM User for n8n, With Least Privilege

n8n can authenticate to AWS with an access key, or by assuming a role; its AWS credentials page describes both, and recommends system credentials if n8n runs on AWS itself. The simplest setup is a dedicated IAM user whose policy allows only what this workflow does: call Rekognition, read and delete in uploads/, write to approved/ and quarantine/, and manage its own SNS subscription. (The original post used AmazonRekognitionReadOnlyAccess, which is broader than needed and doesn’t cover S3 or SNS.) These are the actions n8n’s SNS trigger calls: list topics, subscribe, unsubscribe, list subscriptions and confirm the subscription.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ModerateImages",
      "Effect": "Allow",
      "Action": "rekognition:DetectModerationLabels",
      "Resource": "*"
    },
    {
      "Sid": "ReadAndRemoveUploads",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-app-image-uploads/uploads/*"
    },
    {
      "Sid": "WriteSortedImages",
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": [
        "arn:aws:s3:::my-app-image-uploads/approved/*",
        "arn:aws:s3:::my-app-image-uploads/quarantine/*"
      ]
    },
    {
      "Sid": "ListTopicsForTheNodePicker",
      "Effect": "Allow",
      "Action": "sns:ListTopics",
      "Resource": "*"
    },
    {
      "Sid": "ManageOwnSubscription",
      "Effect": "Allow",
      "Action": ["sns:Subscribe", "sns:Unsubscribe", "sns:ListSubscriptionsByTopic", "sns:ConfirmSubscription"],
      "Resource": "arn:aws:sns:us-east-1:111122223333:image-upload-events"
    }
  ]
}

Create the user, attach this as an inline policy, create an access key, and store it in n8n as an AWS (IAM) credential with the right region. Don’t reuse an administrator key. Rotate the access key regularly.

Step 5: Import the n8n Workflow

Paste this JSON into a new workflow (Import from clipboard), then set the AWS and Slack credentials on the nodes, and your topic and Slack channel. Change the topic ARN in the first node to yours.

{
  "name": "Image moderation: S3 upload -> Rekognition -> approve or quarantine",
  "nodes": [
    {
      "parameters": {
        "authentication": "iam",
        "topic": {
          "__rl": true,
          "mode": "id",
          "value": "arn:aws:sns:us-east-1:111122223333:image-upload-events"
        }
      },
      "name": "S3 upload notification (SNS)",
      "type": "n8n-nodes-base.awsSnsTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "webhookId": "00000000-0000-0000-0000-000000000011",
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Turns SNS notifications (which wrap the S3 event as a JSON string) into one item per uploaded image.\nconst out = [];\nfor (const item of $input.all()) {\n  let msg;\n  try { msg = JSON.parse(item.json.Message); } catch { continue; }\n  if (!msg || !Array.isArray(msg.Records)) continue;   // e.g. the \"s3:TestEvent\" S3 sends when you set up notifications\n  for (const r of msg.Records) {\n    if (!String(r.eventName || '').startsWith('ObjectCreated')) continue;\n    const bucket = r.s3.bucket.name;\n    const key = decodeURIComponent(r.s3.object.key.replace(/\\+/g, ' '));   // S3 URL-encodes keys, spaces become \"+\"\n    if (!key.startsWith('uploads/')) continue;                              // never re-process our own output prefixes\n    if (!/\\.(jpe?g|png)$/i.test(key)) continue;                             // Rekognition moderation accepts JPEG and PNG only\n    const rest = key.slice('uploads/'.length);\n    out.push({ json: {\n      bucket, key, size: r.s3.object.size,\n      approvedKey: 'approved/' + rest,\n      quarantineKey: 'quarantine/' + rest,\n    } });\n  }\n}\nreturn out;"
      },
      "name": "Parse S3 event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "resource": "image",
        "operation": "analyze",
        "type": "detectModerationLabels",
        "binaryData": false,
        "bucket": "={{ $json.bucket }}",
        "name": "={{ $json.key }}",
        "additionalFields": {
          "minConfidence": 50
        }
      },
      "name": "Rekognition: detect moderation labels",
      "type": "n8n-nodes-base.awsRekognition",
      "typeVersion": 1,
      "position": [
        480,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Applies our moderation policy to Rekognition's labels. Runs once per image.\nconst BLOCK = ['Explicit', 'Violence', 'Visually Disturbing', 'Hate Symbols'];                  // top-level (L1) categories\nconst REVIEW = ['Non-Explicit Nudity of Intimate parts and Kissing', 'Swimwear or Underwear',\n                'Drugs & Tobacco', 'Alcohol', 'Rude Gestures', 'Gambling'];\nconst REJECT_AT = 80;   // confidence at or above this on a BLOCK category -> quarantine automatically\nconst REVIEW_AT = 60;   // confidence at or above this on any listed category -> hold for a human\n\nconst labels = ($json.ModerationLabels || []).filter(l => l.TaxonomyLevel === 1 || !l.ParentName);\nlet decision = 'approve';\nconst reasons = [];\nfor (const l of labels) {\n  if (BLOCK.includes(l.Name) && l.Confidence >= REJECT_AT) { decision = 'reject'; reasons.push(`${l.Name} (${l.Confidence.toFixed(1)}%)`); }\n  else if ((BLOCK.includes(l.Name) || REVIEW.includes(l.Name)) && l.Confidence >= REVIEW_AT) {\n    if (decision !== 'reject') decision = 'review';\n    reasons.push(`${l.Name} (${l.Confidence.toFixed(1)}%)`);\n  }\n}\nconst src = $('Parse S3 event').item.json;\nreturn { json: { ...src, decision, reasons, reasonText: reasons.join(', ') || 'no labels over threshold',\n                 modelVersion: $json.ModerationModelVersion || null,\n                 destKey: decision === 'approve' ? src.approvedKey : src.quarantineKey } };",
        "mode": "runOnceForEachItem"
      },
      "name": "Apply policy",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "is-approved",
              "leftValue": "={{ $json.decision }}",
              "rightValue": "approve",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "name": "Approved?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        960,
        0
      ]
    },
    {
      "parameters": {
        "resource": "file",
        "operation": "copy",
        "sourcePath": "=/{{ $json.bucket }}/{{ $json.key }}",
        "destinationPath": "=/{{ $json.bucket }}/{{ $json.destKey }}"
      },
      "name": "Copy to approved/",
      "type": "n8n-nodes-base.awsS3",
      "typeVersion": 2,
      "position": [
        1200,
        -120
      ],
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "resource": "file",
        "operation": "delete",
        "bucketName": "={{ $('Approved?').item.json.bucket }}",
        "fileKey": "={{ $('Approved?').item.json.key }}",
        "options": {}
      },
      "name": "Delete original (approved)",
      "type": "n8n-nodes-base.awsS3",
      "typeVersion": 2,
      "position": [
        1440,
        -120
      ],
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "resource": "file",
        "operation": "copy",
        "sourcePath": "=/{{ $json.bucket }}/{{ $json.key }}",
        "destinationPath": "=/{{ $json.bucket }}/{{ $json.destKey }}"
      },
      "name": "Copy to quarantine/",
      "type": "n8n-nodes-base.awsS3",
      "typeVersion": 2,
      "position": [
        1200,
        120
      ],
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "resource": "file",
        "operation": "delete",
        "bucketName": "={{ $('Approved?').item.json.bucket }}",
        "fileKey": "={{ $('Approved?').item.json.key }}",
        "options": {}
      },
      "name": "Delete original (quarantined)",
      "type": "n8n-nodes-base.awsS3",
      "typeVersion": 2,
      "position": [
        1440,
        120
      ],
      "credentials": {
        "aws": {
          "id": "REPLACE",
          "name": "AWS (moderation)"
        }
      }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "C0123456789"
        },
        "text": "=:warning: Image held for moderation ({{ $('Approved?').item.json.decision }})\nFile: s3://{{ $('Approved?').item.json.bucket }}/{{ $('Approved?').item.json.destKey }}\nReason: {{ $('Approved?').item.json.reasonText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "name": "Alert moderators in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1680,
        120
      ],
      "webhookId": "00000000-0000-0000-0000-000000000012",
      "credentials": {
        "slackApi": {
          "id": "REPLACE",
          "name": "Slack bot token"
        }
      }
    }
  ],
  "connections": {
    "S3 upload notification (SNS)": {
      "main": [
        [
          {
            "node": "Parse S3 event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse S3 event": {
      "main": [
        [
          {
            "node": "Rekognition: detect moderation labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rekognition: detect moderation labels": {
      "main": [
        [
          {
            "node": "Apply policy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply policy": {
      "main": [
        [
          {
            "node": "Approved?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Approved?": {
      "main": [
        [
          {
            "node": "Copy to approved/",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Copy to quarantine/",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Copy to approved/": {
      "main": [
        [
          {
            "node": "Delete original (approved)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Copy to quarantine/": {
      "main": [
        [
          {
            "node": "Delete original (quarantined)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Delete original (quarantined)": {
      "main": [
        [
          {
            "node": "Alert moderators in Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

Activate (publish) the workflow. n8n’s SNS trigger subscribes to the topic when the workflow is activated, using its production webhook URL; n8n then answers Amazon’s subscription-confirmation request itself and verifies the signature on every incoming message, rejecting ones that aren’t properly signed.

How each part works

  • S3 upload notification (SNS): receives the raw SNS message. Per the node documentation, the trigger fires on new SNS events. The S3 event is a JSON string in Message.
  • Parse S3 event (Code): parses that string, ignores the S3 test event, decodes the object key (S3 URL-encodes keys and uses “+” for spaces), skips anything outside uploads/ or that isn’t a JPEG or PNG, and prepares destination keys. It handles several records per message.
  • Rekognition: detect moderation labels: the AWS Rekognition node with Analyze and type Detect Moderation Labels, reading the image from the bucket and key rather than downloading it into n8n. That avoids moving image bytes through your server, and lets you handle larger files (S3 objects can be up to 15 MB; images sent as raw bytes only up to 5 MB, per AWS’s quotas). Minimum confidence is 50, the API’s default. “Retry on fail” is on, with three tries and a two-second wait, which is n8n’s way of handling transient errors like throttling.
  • Apply policy (Code): the policy script below.
  • Approved?: splits on the decision. The false branch handles both “review” and “reject”.
  • S3 copy and delete nodes: S3 has no “move”, so copy then delete. Paths use the /bucket/key format.
  • Alert moderators in Slack: posts the decision, destination and reasons.

The Parsing Code

This is the logic in Parse S3 event. I ran it against sample SNS bodies: an S3 test event (ignored), a JPEG with spaces and parentheses in its name (decoded correctly), and a PDF and an approved/ key (both skipped):

// Turns SNS notifications (which wrap the S3 event as a JSON string) into one item per uploaded image.
const out = [];
for (const item of $input.all()) {
  let msg;
  try { msg = JSON.parse(item.json.Message); } catch { continue; }
  if (!msg || !Array.isArray(msg.Records)) continue;   // e.g. the "s3:TestEvent" S3 sends when you set up notifications
  for (const r of msg.Records) {
    if (!String(r.eventName || '').startsWith('ObjectCreated')) continue;
    const bucket = r.s3.bucket.name;
    const key = decodeURIComponent(r.s3.object.key.replace(/\+/g, ' '));   // S3 URL-encodes keys, spaces become "+"
    if (!key.startsWith('uploads/')) continue;                              // never re-process our own output prefixes
    if (!/\.(jpe?g|png)$/i.test(key)) continue;                             // Rekognition moderation accepts JPEG and PNG only
    const rest = key.slice('uploads/'.length);
    out.push({ json: {
      bucket, key, size: r.s3.object.size,
      approvedKey: 'approved/' + rest,
      quarantineKey: 'quarantine/' + rest,
    } });
  }
}
return out;

The Moderation Policy

This is where “real-world” moderation lives. Rekognition returns labels in a three-level hierarchy. According to AWS’s moderation documentation, AWS recommends using the top-level (L1) and second-level (L2) categories to moderate, and L3 only to carve out specific concepts you don’t want to moderate. The L1 categories are Explicit; Non-Explicit Nudity of Intimate parts and Kissing; Swimwear or Underwear; Violence; Visually Disturbing; Drugs & Tobacco; Alcohol; Rude Gestures; Gambling; and Hate Symbols. The API returns a top-level label along with any second-level labels found, each with a confidence score, plus ModerationModelVersion so you can track model changes.

The script below implements a policy with three outcomes. Treat the category lists and thresholds as a starting point to tune with your own images, not a recommendation:

  • Reject (quarantine and alert): a top-level category in the “block” list (Explicit, Violence, Visually Disturbing, Hate Symbols) at 80% confidence or higher.
  • Review (quarantine and alert, for a human to decide): a block-list category at 60% to 80%, or a category in the “review” list (suggestive content, swimwear, drugs and tobacco, alcohol, rude gestures, gambling) at 60% or higher.
  • Approve: everything else.
// Applies our moderation policy to Rekognition's labels. Runs once per image.
const BLOCK = ['Explicit', 'Violence', 'Visually Disturbing', 'Hate Symbols'];                  // top-level (L1) categories
const REVIEW = ['Non-Explicit Nudity of Intimate parts and Kissing', 'Swimwear or Underwear',
                'Drugs & Tobacco', 'Alcohol', 'Rude Gestures', 'Gambling'];
const REJECT_AT = 80;   // confidence at or above this on a BLOCK category -> quarantine automatically
const REVIEW_AT = 60;   // confidence at or above this on any listed category -> hold for a human

const labels = ($json.ModerationLabels || []).filter(l => l.TaxonomyLevel === 1 || !l.ParentName);
let decision = 'approve';
const reasons = [];
for (const l of labels) {
  if (BLOCK.includes(l.Name) && l.Confidence >= REJECT_AT) { decision = 'reject'; reasons.push(`${l.Name} (${l.Confidence.toFixed(1)}%)`); }
  else if ((BLOCK.includes(l.Name) || REVIEW.includes(l.Name)) && l.Confidence >= REVIEW_AT) {
    if (decision !== 'reject') decision = 'review';
    reasons.push(`${l.Name} (${l.Confidence.toFixed(1)}%)`);
  }
}
const src = $('Parse S3 event').item.json;
return { json: { ...src, decision, reasons, reasonText: reasons.join(', ') || 'no labels over threshold',
                 modelVersion: $json.ModerationModelVersion || null,
                 destKey: decision === 'approve' ? src.approvedKey : src.quarantineKey } };

I tested this with sample label arrays: an empty result (approve), “Explicit” at 91% (reject), “Explicit” at 70% (review), “Alcohol” at 99% (review, destination in quarantine/) and “Gambling” at 55% (approve). If the Rekognition node’s output field names differ in your n8n version, open the node’s output in the editor and adjust ModerationLabels, TaxonomyLevel and ParentName accordingly.

A policy tip: because the model’s top-level label is returned together with its children, you can also act on L2 labels (for example, quarantine “Explicit Nudity” but only review “Partially Exposed Female Breast”). Start simple and add rules when you see real mistakes.

Testing

  1. Use a throwaway bucket and a test Slack channel.
  2. Upload an ordinary photo (a landscape, a product). It should appear under approved/ and disappear from uploads/, with no Slack message.
  3. Upload images that trigger your categories using non-explicit test material that is safe to handle (for example a photo of a drinks bottle for the Alcohol rule, or a stock image of a toy weapon). Don’t use illegal or genuinely harmful images for testing. They should land in quarantine/ with a Slack alert.
  4. Upload a PDF and a very small image (under 80 pixels). Rekognition accepts only JPEG and PNG and requires at least 80 pixels in each dimension, so these will either be skipped by the parser or fail at the Rekognition step.
  5. Check the executions list in n8n for each, and look at the raw Rekognition output for borderline images to tune thresholds.

Tuning thresholds is a trade-off. After a week of real traffic, have a person review a sample of approved images (to find misses) and all quarantined images (to find false positives). Move thresholds, then repeat.

Limits, Errors and Cost

  • Image limits (from AWS’s quotas page): JPEG and PNG only; at least 80 pixels in each dimension and at most 10,000 pixels for moderation; 15 MB as an S3 object, 5 MB as raw bytes. Decide what happens to other file types, because this workflow ignores them; you may want to reject uploads of unsupported types in your application before they reach S3.
  • Throttling. Rekognition has default per-region transactions-per-second limits that you can view in Service Quotas and ask AWS to raise. AWS’s guidance is to smooth spiky traffic with a queue, configure retries, and use exponential backoff with jitter. n8n’s retry-on-fail setting covers occasional throttling; for big upload bursts, put an SQS queue between S3 and n8n.
  • Failures. If a Rekognition call or an S3 copy fails after retries, the image stays in uploads/ and nothing alerts you. Set an Error Workflow in the workflow settings (n8n runs a separate workflow that starts with an Error Trigger node whenever an execution fails) that posts to Slack, and periodically list uploads/ for old leftovers. The old post’s advice to wrap each node in an Error Trigger node isn’t how it works: the Error Trigger belongs at the start of a separate error workflow.
  • Cost. AWS pricing lists image moderation at about $0.001 per image for the first million images each month (cheaper in higher tiers), with 1,000 free images per month for the first 12 months of an account. S3 storage, requests, SNS and data transfer add small amounts. A custom moderation adapter is available at extra cost if the stock model misjudges your content.
  • Human review at scale. AWS integrates Rekognition with Amazon Augmented AI for human review workflows. For a small team, a Slack channel and a quarantine folder is enough to start.
  • Retention. Add S3 lifecycle rules to delete old quarantined images after a set number of days, and keep a log of each decision (image key, decision, reasons, model version, timestamp) for audits and appeals. Append that to a database or sheet from the workflow.

Extending It

  • Text moderation: captions and comments need a different tool (for example Amazon Comprehend or a language model).
  • Video: Rekognition has separate asynchronous video moderation APIs.
  • Custom moderation adapter: train one if the base model repeatedly misjudges your kind of images.
  • Appeals: add a way for users to contest decisions, and a moderator action that moves an image from quarantine/ to approved/.

Can Amazon Rekognition detect illegal content such as CSAM?

No. AWS states that its image and video moderation APIs do not detect whether an image includes illegal content such as CSAM, and that Rekognition is not an exhaustive filter of inappropriate content. If your platform accepts public uploads, get legal advice about your obligations and use specialised tools for illegal content.

Which image formats and sizes does Rekognition moderation support?

JPEG and PNG only. Images must be at least 80 pixels in each dimension and at most 10,000 pixels for moderation, with a maximum of 15 MB when stored in S3 and 5 MB when sent as raw bytes.

What confidence threshold should I use?

The API returns labels at 50% confidence or higher by default, and lower values return many false positives. Start with two tiers, for example automatic quarantine at 80% and human review from 60%, then tune with a sample of your real images. There is no universal right value.

Why use SNS instead of an SQS trigger in n8n?

n8n has an AWS SNS Trigger node but no SQS trigger node. S3 can publish events to an SNS topic, and n8n subscribes to that topic over HTTPS. For heavy bursts, add an SQS queue and a worker in front of n8n.

How much does image moderation with Rekognition cost?

AWS lists about $0.001 per image for the first million images per month, with lower rates at higher volumes and 1,000 free images per month during the first 12 months. Check the AWS pricing page for current rates, and add the small costs of S3, SNS and data transfer.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts