CodeHerderSearch⌘KRequest access →

Self-hosted Cognito sign-in

Install the pre-token Lambda, grant the server its Cognito permissions, check them, alert on a denied revoke and turn on threat protection.

This page applies when your server runs in Cognito mode. That means CH_COGNITO_USER_POOL_ID is set and the user pool is in your AWS account. In OIDC mode there is no pool, and deprovision skips the identity provider.

When you deprovision a member, the server revokes their CodeHerder keys in its database. It then calls Cognito to sign the person out and disable their pool login. If the server cannot make that call, the database part still holds. But the person can still sign in to the pool.

Install the pre-token Lambda

The server reads three claims from the Cognito access token: email, email_verified and identity_provider. A stock Cognito access token carries none of them. Without the Lambda, every sign-in fails with a 401 and the server logs reason=missing_email. A Pre Token Generation trigger adds the claims. To avoid the Lambda, use OIDC mode instead.

The trigger must add all three claims in one object. A token with email but no identity_provider makes a federated user look native. That user then skips the federated signup gate. The reference function below fails closed. If the user has no email, or the identities attribute does not parse, it adds no claims. The server then rejects the token.

Access-token customisation needs trigger event version V2_0. That version needs the Cognito Essentials or Plus feature plan. Check the current AWS documentation for your plan. Set user_pool_tier to ESSENTIALS or PLUS on the pool.

// Cognito Pre Token Generation trigger, event version V2_0.
// It adds email, email_verified and identity_provider to the ACCESS token.
// The server reads all three. It fails closed: on any doubt it adds nothing,
// so the server rejects the token as missing_email.
export const handler = async (event) => {
  try {
    const attrs = event?.request?.userAttributes ?? {};
    const email = attrs.email;
    const provider = identityProvider(attrs.identities);
    if (typeof email !== "string" || email === "" || provider === null) {
      return event;
    }
    const claims = {
      email,
      email_verified: attrs.email_verified === "true" ? "true" : "false",
      identity_provider: provider,
    };
    for (const name of ["name", "given_name", "family_name"]) {
      if (typeof attrs[name] === "string" && attrs[name] !== "") {
        claims[name] = attrs[name];
      }
    }
    event.response = event.response ?? {};
    event.response.claimsAndScopeOverrideDetails = {
      ...event.response.claimsAndScopeOverrideDetails,
      accessTokenGeneration: { claimsToAddOrOverride: claims },
    };
  } catch {
    // Add no claims. Never log the event: it holds the email address.
  }
  return event;
};

// "COGNITO" for a native user. The first provider name for a federated one.
// null when the attribute does not parse, so the caller adds no claims.
function identityProvider(identities) {
  if (identities === undefined || identities === null || identities === "") {
    return "COGNITO";
  }
  let list;
  try {
    list = JSON.parse(identities);
  } catch {
    return null;
  }
  if (!Array.isArray(list)) return null;
  if (list.length === 0) return "COGNITO";
  const name = list[0]?.providerName;
  return typeof name === "string" && name !== "" ? name : null;
}

Package the file as index.mjs in a zip. Use the Node.js 20 or later runtime, with the handler index.handler. Add these resources to the Terraform for your pool. Replace LAMBDA_ROLE_ARN with a role that has only the basic Lambda logging policy.

resource "aws_lambda_function" "pre_token" {
  function_name    = "codeherder-pre-token"
  role             = "LAMBDA_ROLE_ARN"
  runtime          = "nodejs20.x"
  handler          = "index.handler"
  filename         = "pre-token.zip"
  source_code_hash = filebase64sha256("pre-token.zip")
}

resource "aws_lambda_permission" "pre_token" {
  statement_id  = "AllowCognitoInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.pre_token.function_name
  principal     = "cognito-idp.amazonaws.com"
  source_arn    = aws_cognito_user_pool.main.arn
}

resource "aws_cognito_user_pool" "main" {
  # ... your existing settings ...
  user_pool_tier = "ESSENTIALS"

  lambda_config {
    pre_token_generation_config {
      lambda_arn     = aws_lambda_function.pre_token.arn
      lambda_version = "V2_0"
    }
  }
}

To use the console instead, create the function, then open your pool. Go to Extensions, add a Pre token generation trigger, choose the function, and choose trigger event version V2_0.

Check the result. Sign in as a test user and decode the access token payload. The payload must hold email, email_verified and identity_provider. A native user has identity_provider set to COGNITO. Do not paste a real token into a website to decode it. Decode it locally.

Grant the lifecycle permissions

The server calls Cognito with the AWS credentials it finds by default. That is the instance role, or the IAM user whose keys the server holds. Grant that principal four actions, on your pool’s ARN only:

Action Used when
cognito-idp:AdminUserGlobalSignOut You deprovision a member
cognito-idp:AdminDisableUser You deprovision a member
cognito-idp:AdminEnableUser You reactivate a member
cognito-idp:AdminDeleteUser An instance operator erases a human

Use this inline policy. Replace POOL_ARN with the ARN of your user pool. Find it in the Cognito console, or with aws cognito-idp describe-user-pool. Grant the permissions on that one pool only.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "cognito-idp:AdminUserGlobalSignOut",
        "cognito-idp:AdminDisableUser",
        "cognito-idp:AdminEnableUser",
        "cognito-idp:AdminDeleteUser"
      ],
      "Resource": "POOL_ARN"
    }
  ]
}

Check the permissions

Run this command with an identity that can call IAM. Use the ARN of the server’s role or user.

aws iam simulate-principal-policy \
  --policy-source-arn SERVER_PRINCIPAL_ARN \
  --action-names cognito-idp:AdminUserGlobalSignOut cognito-idp:AdminDisableUser \
                 cognito-idp:AdminEnableUser cognito-idp:AdminDeleteUser \
  --resource-arns POOL_ARN \
  --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text

Each line must end with allowed. Any other value means the policy is missing or too narrow.

Then run a live check. Deprovision a test member, and read the pool user:

aws cognito-idp admin-get-user --user-pool-id POOL_ID --username COGNITO_SUB

The output must show "Enabled": false. Reactivate the test member afterwards.

Alert on a denied revoke

When IAM denies the revoke, the server writes this warning. The line is logfmt, and the message text is fixed:

level=WARN msg="members: cognito identity revoke denied by IAM policy" member_id=... cognito_access_denied=true error=...

A denied reactivate writes the same line with restore in place of revoke. Alert on the field cognito_access_denied=true. It matches both lines.

To search the journal by hand:

journalctl -u codeherder.service | grep 'cognito_access_denied=true'

To ship the logs to your own storage, see Self-hosted logs.

When the alert fires, fix the policy and run the check above. Then deprovision the member again. The call is safe to repeat. Or disable the user in the pool yourself.

Turn on threat protection

Cognito threat protection has two parts. Compromised-credential detection blocks a sign-in that uses a password found in a known leak. Adaptive authentication scores each sign-in for risk, and can ask for a second factor or block it.

Both need the Cognito Plus feature plan. Plus costs extra for each monthly active user. Check the current price on the AWS Cognito pricing page.

Start in AUDIT mode. Cognito then records its decisions and takes no action. Read the results for a few weeks. Then move to ENFORCED.

Add these blocks to the Terraform for your pool. The block sets advanced_security_mode to AUDIT. Change it to ENFORCED after the review period.

resource "aws_cognito_user_pool" "main" {
  # ... your existing settings ...
  user_pool_tier = "PLUS"

  user_pool_add_ons {
    advanced_security_mode = "AUDIT"
  }
}

resource "aws_cognito_risk_configuration" "main" {
  user_pool_id = aws_cognito_user_pool.main.id

  compromised_credentials_risk_configuration {
    event_filter = ["SIGN_IN", "PASSWORD_CHANGE", "SIGN_UP"]
    actions {
      event_action = "BLOCK"
    }
  }

  account_takeover_risk_configuration {
    notify_configuration {
      source_arn = "SES_IDENTITY_ARN"
      from       = "no-reply@example.com"
    }
    actions {
      low_action {
        event_action = "NO_ACTION"
        notify       = false
      }
      medium_action {
        event_action = "MFA_IF_CONFIGURED"
        notify       = true
      }
      high_action {
        event_action = "BLOCK"
        notify       = true
      }
    }
  }
}

To use the console instead, open your pool in the Amazon Cognito console. Go to Threat protection, choose the Plus plan, and set the mode. Then set the compromised-credential and adaptive actions.

Token lifetimes

The pool’s app client sets the Cognito access and refresh token lifetimes. For the server-side browser session limits, see Self-hosted deployment.

Last updated

CodeHerder

Round up your herd.

Bring every human and every agent onto one table. Watch the work move. Costs update as it happens.

Try "pricing", "connect a device", or "who reviews the code"

↑↓ move · ↵ open · esc close