Skip to content

extract_team_id crashes with TypeError when payload["user"] is a string (e.g. member_joined_channel events) #1447

Description

@dbermuehler

Reproducible in:

The slack_bolt version

slack-bolt==1.27.0
slack-sdk==3.39.0

Python runtime version

Python 3.14.0

OS info

ProductName:    macOS
ProductVersion: 26.3
BuildVersion:   25D125
Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020

Steps to reproduce:

  1. Register a member_joined_channel listener in a Slack Bolt app:
@app.event("member_joined_channel")
def handle_channel_join(event, client, say):
    say("Hello!")
  1. Add the bot to any Slack channel.

  2. Observe the incoming event payload — Slack sends "user" as a plain string (the user ID):

{
  "type": "member_joined_channel",
  "user": "U0123456789",
  "channel": "C0123456789",
  "team": "T0123456789"
}

Expected result:

The listener is invoked normally.

Actual result:

The request crashes before the listener is reached, with:

TypeError: string indices must be integers, not 'str'

File "slack_bolt/adapter/aws_lambda/handler.py", line 87, in to_bolt_request
    return BoltRequest(
File "slack_bolt/request/request.py", line 69, in __init__
    self.context = build_context(BoltContext(context if context else {}), self.body)
File "slack_bolt/request/internals.py", line 276, in build_context
    team_id = extract_team_id(body)
File "slack_bolt/request/internals.py", line 116, in extract_team_id
    return payload["user"]["team_id"]

Root cause

extract_team_id in slack_bolt/request/internals.py assumes payload["user"] is always a dict:

if payload.get("user") is not None:
    return payload["user"]["team_id"]

But member_joined_channel sends "user" as a plain string (the user ID). Accessing "U0123456789"["team_id"] raises the TypeError.

The same function already handles payload["team"] correctly with an isinstance check:

if payload.get("team") is not None:
    team = payload.get("team")
    if isinstance(team, str):
        return team
    elif team and "id" in team:
        return team.get("id")

The same guard is missing for payload["user"]. This pattern (subscribing to member_joined_channel and checking if the joined user is the bot) is what maintainers recommend in #710, so users following that guidance will hit this bug.

Activity

  1. changed the title [-]extract_team_id crashes with TypeError when payload["user"] is a string (e.g. member_joined_channel events)[/-] [+]`extract_team_id` crashes with `TypeError when payload["user"]` is a string (e.g. member_joined_channel events)[/+] on Feb 22, 2026
  2. changed the title [-]`extract_team_id` crashes with `TypeError when payload["user"]` is a string (e.g. member_joined_channel events)[/-] [+]`extract_team_id` crashes with `TypeError when payload["user"]` is a string (e.g. `member_joined_channel` events)[/+] on Feb 22, 2026
  3. WilliamBergamin commented on Feb 23, 2026

    @WilliamBergamin
    Contributor

    Hi @dbermuehler 👋 thanks for writing in!

    I tried to reproduce this behavior with the following app

    import os
    import logging
    
    from slack_bolt import App
    from slack_bolt.adapter.socket_mode import SocketModeHandler
    
    logging.basicConfig(level=logging.DEBUG)
    
    app = App(token=os.environ.get("SLACK_BOT_TOKEN"))
    
    
    @app.event("member_joined_channel")
    def handle_channel_join(event, client, say):
        say("Hello!")
    
    
    if __name__ == "__main__":
        SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start()
    manifest.json
    {
      "_metadata": {
          "major_version": 1,
          "minor_version": 1
      },
      "display_information": {
          "name": "member_joined_channel"
      },
      "features": {
          "bot_user": {
              "display_name": "member_joined_channel",
              "always_online": false
          }
      },
      "oauth_config": {
          "scopes": {
              "bot": [
                  "channels:read",
                  "chat:write"
              ]
          }
      },
      "settings": {
          "event_subscriptions": {
              "bot_events": [
                  "member_joined_channel"
              ]
          },
          "org_deploy_enabled": true,
          "socket_mode_enabled": true,
          "token_rotation_enabled": false
      }
    }

    I was not able to reproduce your behavior, the error you shared in your description was not encountered in my testing 🤔

    Example payload
    {
    		"token": "q123",
    		"team_id": "T123",
    		"context_team_id": "T123",
    		"context_enterprise_id": null,
    		"api_app_id": "A123",
    		"event": {
    			"type": "member_joined_channel",
    			"user": "U123",
    			"channel": "C123",
    			"channel_type": "C",
    			"team": "T123",
    			"inviter": "U123",
    			"event_ts": "123"
    		},
    		"type": "event_callback",
    		"event_id": "E123",
    		"event_time": 123,
    		"authorizations": [
    			{
    				"enterprise_id": null,
    				"team_id": "T123,
    				"user_id": "U123",
    				"is_bot": true,
    				"is_enterprise_install": false
    			}
    		],
    		"is_ext_shared_channel": false,
    		"event_context": "4-123"
    	},

    The current implementation of extract_team_id will go through the following flow

    1. Finds that event is in the payload
    2. Recessively call extract_team_id(payload["event"])
    3. Find that team is in the new payload and that it is a str
    4. return that value

    Since team is always in this event, the team_id value will always be returned before payload['event']['user'] is assumed to be a dict

    If there are different testing steps I could follow to reproduce your behavior, please share them with me

  4. added
    questionFurther information is requested
    and removed
    questionFurther information is requested
    on Feb 23, 2026
  5. dbermuehler commented on Feb 24, 2026

    @dbermuehler
    Author

    Thanks for looking into this @WilliamBergamin — you're right, team is always present in real Slack member_joined_channel events, so extract_team_id returns before reaching payload["user"].

    After investigating further, we believe the crash was caused by non-Slack traffic (scrapers/bots) hitting our public API Gateway endpoint with malformed payloads that were missing the team field. We're running the Bolt app in an AWS Lambda function behind API Gateway using SlackRequestHandler, so the endpoint is publicly reachable. Unfortunately we don't have logs of the raw request body, so we can't confirm the exact payload that caused the crash.

    We expected RequestVerification to reject these unsigned requests, but the crash happens before signature verification runs.

    The issue is an ordering problem in the AWS Lambda adapter. Here's the full chain:

    1. handler.py#L55 — handle() calls to_bolt_request(event)
    2. handler.py#L87-L91 — constructs BoltRequest(body=body, ...)
    3. request.py#L63 — BoltRequest.__init__() parses the body
    4. request.py#L69 — calls build_context() on the parsed body
    5. internals.py#L276 — build_context() calls extract_team_id(body)
    6. internals.py#L115-L116 — crashes on payload["user"]["team_id"] when user is a string

    The BoltRequest construction fails, so execution never reaches:

    1. handler.py#L61 — app.dispatch(bolt_req)
    2. app.py#L559-L563 — iterates _middleware_list
    3. app.py#L411 — RequestVerification is registered in that list
    4. request_verification.py#L42 — signature would be checked here

    The crash at step 6 prevents the signature check at step 10 from ever running. We worked around this by verifying the Slack signature ourselves before calling into slack_bolt, but it might be worth considering moving signature verification ahead of body parsing in the adapter layer itself.

  6. dbermuehler commented on Feb 24, 2026

    @dbermuehler
    Author

    We managed to reproduce this. Here's a minimal PoC using the AWS Lambda adapter with real credentials and RequestVerification enabled (the default):

    """PoC: Malformed request crashes slack_bolt before signature verification runs.
    
    Simulates a scraper sending a crafted payload to a Bolt app behind AWS API Gateway.
    The payload has "user" as a string but no "team" field, causing extract_team_id()
    to crash during BoltRequest construction — before RequestVerification middleware
    ever gets a chance to reject the unsigned request.
    """
    
    import json
    
    from slack_bolt import App
    from slack_bolt.adapter.aws_lambda.handler import SlackRequestHandler
    
    app = App(signing_secret="your-signing-secret", token="xoxb-your-bot-token")
    
    
    @app.event("member_joined_channel")
    def handle_channel_join(event, say):
        say("Hello!")
    
    
    handler = SlackRequestHandler(app)
    
    # Malformed payload: "user" is a string, "team" is missing.
    # A real Slack payload always includes "team", but a scraper doesn't know that.
    malformed_event = {
        "requestContext": {"http": {"method": "POST"}},
        "headers": {},
        "body": json.dumps(
            {
                "type": "event_callback",
                "event": {
                    "type": "member_joined_channel",
                    "user": "U0123456789",
                    "channel": "C0123456789",
                },
            }
        ),
        "isBase64Encoded": False,
    }
    
    
    class FakeLambdaContext:
        function_name = "test"
        invoked_function_arn = "arn:aws:lambda:eu-west-1:123:function:test"
    
    
    try:
        response = handler.handle(malformed_event, FakeLambdaContext())
        print(f"Response: {response}")
    except TypeError as e:
        print(f"CRASHED with TypeError: {e}")

    Output:

    CRASHED with TypeError: string indices must be integers, not 'str'
    

    Note that RequestVerification is enabled — the app is configured with a real signing secret. The request has no valid signature, so it should be rejected with 401. Instead it crashes because BoltRequest.__init__() parses the body and calls extract_team_id() before dispatch() runs the RequestVerification middleware.

  7. dbermuehler commented on Mar 17, 2026

    @dbermuehler
    Author

    @WilliamBergamin just wanted to follow up on this: Since this seems like a different issue than the one for which I originally created the issue, should we close this one and create a new issue?

  8. WilliamBergamin commented on Mar 18, 2026

    @WilliamBergamin
    Contributor

    This does seems like an a potential edge case issue 🤔 Its an edge case since this error would only show up if some rogue request is able to reach your application

    The ideal solution would be to move the context initialization into a middleware that runs after the RequestVerification middleware ⚠️ But this would constitute a breaking change and would require a major release

    As a temporary fix we might be able to improve the robustness of our build_context logic so that it is less likely to cause issues before reaching the RequestVerification middleware.

  9. dbermuehler commented on Apr 4, 2026

    @dbermuehler
    Author

    This does seems like an a potential edge case issue 🤔 Its an edge case since this error would only show up if some rogue request is able to reach your application

    I would say that this is not a very uncommon edge case. Scanners that crawl the internet for vulnerable endpoints which will send invalid requests are unfortunately a normality in today's internet. A potential solution would be to only allow Slack IP addresses access to the endpoint and block anything else, however, that is not really practical since the addresses are changing constantly and keeping them up to date would be a hassle.

    Also from a security perspective I think it is cleaner and more secure to do input validation as early as possible rather than deep in the code.

    But I totally understand that this might require a bigger change and a major version bump. Looking forward in seeing this change!

  10. WilliamBergamin commented on Apr 13, 2026

    @WilliamBergamin
    Contributor

    Lets keep this open for now, since what we implemented is a patch fix to the root cause

  11. github-actions commented on May 18, 2026

    @github-actions

    👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out. If you think this issue needs to be prioritized, please comment to get the thread going again! Maintainers also review issues marked as stale on a regular basis and comment or adjust status if the issue needs to be reprioritized.

  12. zimeg commented on May 18, 2026

    @zimeg
    Member

    🌚 Keeping open as we track upcoming release 1.29.0 in milestones!

  13. added a commit that references this issue on Oct 8, 2026
    afd575d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions