# Self-hosted Cognito sign-in

Source: https://codeherder.com/docs/self-host-cognito/

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](https://codeherder.com/docs/self-hosting/#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](https://codeherder.com/docs/self-host-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](https://codeherder.com/docs/self-hosting/).

## Related guides

- [Self-hosted deployment](https://codeherder.com/docs/self-hosting/) — install and configure the server
- [Self-hosted logs](https://codeherder.com/docs/self-host-logs/) — log formats and how to ship them
