Where the MAU Bill Hides in a Daily Cognito Audit Job
DEV Community

Where the MAU Bill Hides in a Daily Cognito Audit Job

I'm designing a multi-tenant SaaS that keeps every customer in a single Cognito user pool. Self sign-up is off. Accounts are created by an operator path, each customer has around 200 users, and administrators must use TOTP.

One of the jobs in the plan runs once a day and reconciles two things: email addresses that changed outside the expected flow, and administrators who don't have TOTP set up. It's a small security control. It is also, written the obvious way, a line on the bill that grows with every customer you sign, whether their users log in or not.

This post walks through that design: where the cost comes from, what AWS recommends instead, why the recommendation has its own costs, and the event-driven version I'm moving toward. The system isn't in production yet, so nothing here is a production war story. Every claim is marked as either documented by AWS, reported by someone else, or not verified. The table at the end collects them.

If you haven't read it, the first post on Cognito pricing covers tiers and MAU counting in general. This one is a single case.

The obvious implementation

To check a user's email and MFA state, the natural call is AdminGetUser. It returns the user's attributes and UserMFASettingList, which is where TOTP shows up as SOFTWARE_TOKEN_MFA. Loop over every user, compare against what you expect, alert on differences.

The problem is in the API reference for that call: "This operation contributes to your monthly active user (MAU) count for the purpose of billing." The pricing page and the FAQ say the same thing: an attribute query through AdminGetUser makes a user active for the month. A user who hasn't opened the app in a year becomes a billable user the moment the job reads them.

Run the loop daily and your MAU count stops measuring activity. It measures accounts.

That last point is easy to miss in a design document. An early draft of my own plan said MAU would only include people who sign in. With this job in place, that sentence was wrong.

The numbers

The pool runs on Essentials. Lite would have been enough for TOTP. Essentials is there for one reason: access token customization, which needs a tier above Lite. That choice sets the rate for every MAU in the pool, including the ones the audit job creates.

Essentials includes 10,000 free MAU, then $0.015 per MAU. The free tier applies per account or per AWS organization, not per pool. At 200 users per customer, the free tier runs out at 50 customers.

To compare, assume 40% of accounts actually sign in during a given month. That figure is an assumption for the example, not a measurement.

Customers Accounts Job reads every account Only real activity (40%)
50 10,000 $0 $0
80 16,000 $90 $0
200 40,000 $450 $90
500 100,000 $1,350 $450

Each 1,000 accounts past the free tier adds $15 a month on Essentials. On Lite it would be $5.50.

None of these numbers is dramatic on its own. What bothers me is the shape: the job's cost scales with signed contracts instead of usage, and it starts at zero, so nobody notices it during the first year.

The free tier being shared across the organization adds one more way to hit it early. A staging pool with seeded test accounts and the same daily job running against it counts toward the same 10,000.

The early warning that might not fire

The plan's safety net was an AWS Budgets usage budget on Cognito MAU with an alert at 9,000, so the switch to a cheaper design could happen before the first charge. I haven't confirmed that this works, and there are signs that it doesn't: AWS's list of services with free tier usage tracking names CognitoUserPoolMAU and CognitoEnterpriseMAU. It doesn't list a usage type for Essentials.

Another public project reports that its Cognito usage budget only alerted once billing started, and that the free tier API showed no Cognito line on their account. The same AWS page says the automatic 85% free tier alerts are off for an Organizations management account unless you opt in.

So the alert that everyone would set up may stay silent until the bill already has a Cognito charge on it. The control I trust more is one I own: count customers in the application database and alert when the count passes 45. With 200 users each, that's the same threshold, and it doesn't depend on how billing data is classified.

What AWS recommends: ListUsers

AWS's own guidance is direct. The quotas page says the MAU category "Query detailed attributes of a user as an administrator" includes AdminGetUser "but not ListUsers", and that to avoid additional cost you should "collect user data with ListUsers or store user information in an external database." The cost page has a section titled "ListUsers, not AdminGetUser."

That's documented. What I couldn't find, on re:Post, GitHub or anywhere else, is someone confirming it on a real invoice. I'd still expect it to hold, but I'd watch the first month after switching.

For this job, though, ListUsers has three costs that have nothing to do with the bill.

It can't be scoped per customer. The only service-specific IAM condition keys for Cognito user pools are tag keys. All customers share one pool, so there's nothing to scope by. Whatever role runs the job can read every customer's email addresses. Server-side filtering doesn't help either: ListUsers filters on one standard attribute at a time, and custom attributes aren't searchable, so a custom:tenant_id filter isn't possible.

It doesn't return TOTP state. ListUsers returns MFAOptions, which the API reference describes as no longer supported and limited to SMS. TOTP only appears in UserMFASettingList, which comes from AdminGetUser. The administrator check, half of the job, can't move to ListUsers at all.

It's eventually consistent. The API reference says so, and adds: "To validate the existence or configuration of an individual user, use AdminGetUser." For a security check, that's AWS pointing you back to the call you were trying to avoid.

There's also a rate difference. ListUsers sits in the UserList quota category, 30 requests per second, not adjustable. AdminGetUser is in UserRead, 120 per second, which you can raise by purchasing capacity. At this scale neither is a constraint, but it's worth knowing which one has a ceiling.

Alternatives I ruled out

Only check users who were active. Cognito doesn't store a last sign-in time. The user record has UserCreateDate and UserLastModifiedDate, and the second one changes on any modification, not on sign-in. You'd have to maintain a last-seen column yourself.

Check a slice of users each day. Reading one thirtieth of the pool per day still reads every user once a month. The MAU count is the same, the detection is just slower.

Check the email in the user's token. The ID token documentation says the email claim is present only "when the email attribute is present and readable by the app client" and recommends treating every claim beyond the basic ones as optional. Whether a token issued by a refresh carries the current email isn't documented anywhere I could find. A security control can't rest on that.

The event-driven version

The job's real purpose is to notice a change that didn't come through the operator path. That's an event, and Cognito already records it. From the CloudTrail documentation for Cognito: user pool API calls are logged as management events; for user-specific requests, CloudTrail records the user's sub, not the username; some private fields, such as passwords and tokens, are replaced with HIDDEN_DUE_TO_SECURITY_REASONS.

So the design becomes an EventBridge rule on the calls that change an email or an MFA setting, sending to a small function:

{
  "source": ["aws.cognito-idp"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["cognito-idp.amazonaws.com"],
    "eventName": [
      "AdminUpdateUserAttributes",
      "UpdateUserAttributes",
      "VerifyUserAttribute",
      "VerifySoftwareToken",
      "SetUserMFAPreference",
      "AdminSetUserMFAPreference"
    ]
  }
}

The function ignores calls made by the operator role, since that's the expected path. For anything else, it takes the sub from additionalEventData, reads that one user with AdminGetUser, and compares the result with the application's own record.

import os
import boto3
cognito = boto3.client("cognito-idp")
POOL_ID = os.environ["USER_POOL_ID"]
OPERATOR_ROLE_ARN = os.environ["OPERATOR_ROLE_ARN"]
def handler(event, context):
    detail = event["detail"]
    issuer = (detail.get("userIdentity", {}).get("sessionContext", {}).get("sessionIssuer", {}).get("arn"))
    if issuer == OPERATOR_ROLE_ARN:
        return
    sub = detail.get("additionalEventData", {}).get("sub")
    if not sub:
        raise_alert("event without sub", detail["eventName"])
        return
    user = cognito.admin_get_user(UserPoolId=POOL_ID, Username=username_for(sub))
    check_against_app_record(sub, user)

This is a sketch, not code I've run. Three details matter more than the code.

The event is a trigger, not the data. I don't know whether the new email value appears in the event or comes through hidden, and the design doesn't need to know. The function reads the current state from Cognito either way.

Getting from sub to a username. AdminGetUser takes a username. Its API reference says that, depending on how the pool's sign-in attributes are configured, the value can be the user's sub. If your pool isn't in that case, store the sub next to the username in your own users table when the account is created. Don't look it up with ListUsers and a sub filter: that brings back the pool-wide read permission this design exists to avoid.

The cost. AdminGetUser now runs only for users who just changed something. A user calling UpdateUserAttributes is already signed in, and an operator update through AdminUpdateUserAttributes already counts as an identity operation. I'd expect the extra MAU from this path to be close to zero, but that's reasoning from the documented rules, not a measured result. CloudTrail, EventBridge and Lambda have their own pricing; at these volumes I'd expect it to be small, and I'd check it before quoting a number.

What this design gives up:

  • It sees actions, not state. It catches a change when it happens. It doesn't prove that the current state of every account is correct.
  • It depends on delivery. AWS describes service event delivery to CloudTrail as best effort, and I found no documented latency for the path to EventBridge. A public project that built the same pattern reports that one event type it relied on, AdminAddUserToGroup, never arrived in an earlier feature. I haven't verified delivery for each event in the rule above.
  • It depends on the trail. If logging stops, the detection stops silently. The trail itself needs an alarm.

Because of that, the daily job doesn't disappear. It shrinks to the administrators, a few per customer, and checks their TOTP state with AdminGetUser. Administrators are the accounts most likely to sign in every month anyway, so the job adds little or nothing to MAU.

The decision

Security comes first and cost second. Below 50 customers the full daily job costs nothing, so it stays as it is. The move to the event-driven version is triggered by the customer count in the application database, at 45, not by the budget alert.

Other places the bill can grow

MAU is the main line, but not the only one this design touches:

  • Purchased rate capacity. Adjustable quota categories can be raised in Service Quotas and then set with UpdateProvisionedLimit or the console's Provisioned limits tab. The pricing page lists $20 per RPS-month for ongoing increases and $45 for partial months, with a one-day minimum.
  • Tier changes. Switching tiers mid-month bills each user at the highest tier enabled while they were active.
  • Dependent services. SMS through SNS, email through SES, the Lambda functions behind triggers, and now CloudTrail and EventBridge all bill separately. The first post covers the two controls I'd put on all of it: a budget filtered to Amazon Cognito and Cost Anomaly Detection, keeping in mind the caveat above about what a usage budget can actually see.

What's documented and what isn't

Claim Status
AdminGetUser makes a user an MAU Documented (pricing page, FAQ, API reference)
ListUsers doesn't count toward MAU Documented (quotas and cost pages), not confirmed on a real bill
IAM can't scope ListUsers per tenant in a shared pool Documented (only tag condition keys)
ListUsers is eventually consistent Documented (API reference)
ListUsers doesn't return TOTP state Documented (MFAOptions is SMS only)
Cognito stores no last sign-in time Documented (UserType fields)
The email claim is optional Documented (ID token reference)
A refreshed ID token carries the current email Not documented, not tested
A Budgets usage budget sees Essentials MAU below the free tier Not verified, external report suggests it doesn't
CloudTrail events for each call in the rule reach EventBridge Not verified, external report of one event type missing
The new email value is hidden in the CloudTrail event Not verified, design doesn't depend on it
Sign-in attempts against accounts that don't exist count as MAU Not documented, not tested

I plan to test the unverified rows in a separate AWS account and update this post with what the bill shows.

Sources

  • Quotas in Amazon Cognito: monthly active users and API rate categories
  • Monitoring and managing costs in Amazon Cognito
  • Amazon Cognito pricing
  • AdminGetUser API reference
  • ListUsers API reference
  • UserType API reference
  • Actions, resources, and condition keys for Amazon Cognito User Pools
  • Understanding the ID token
  • Amazon Cognito logging in AWS CloudTrail
  • AWS CloudTrail events in Amazon EventBridge
  • Tracking your AWS Free Tier usage
  • investment-tracker issue #319: Cognito usage budget alerts only with the bill
  • supply-checkout PR #278: detecting changes made directly against Cognito
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.