instagrapi

Handling instagrapi 2FA and challenge_required errors in Python

Updated

What triggers a challenge

challenge_required is Instagram’s polite way of saying “prove you’re the account owner before we go any further.” It is fired by a risk model on the server, not by a single rule, which is why it can feel arbitrary. In practice the model keys off a small set of signals that, taken together, push a request over the line.

The signals that matter most:

  • A new IP address. First login from a residential connection your account has never used will frequently trigger a challenge, especially if the previous activity was from a phone on a different ASN.
  • A new device fingerprint. Every Client() instantiated without a saved session generates fresh Android device IDs. Instagram sees a brand-new phone every run.
  • Request volume. Hammering endpoints — even read-only ones — at a pace no human achieves on a phone is the fastest way to land in challenge purgatory.
  • Account age. Accounts under a month old are flagged hard. Anything that even resembles automation on a fresh account triggers a challenge or worse.
  • Time-of-day drift. Logging in at 3am from an IP geolocated to a country your account has never appeared in is a classic combination.

Instagram retrains the model regularly, so any list goes stale. Treat empirical observation of your own accounts as the source of truth, and assume the bar moves down (toward more challenges) over time.

The challenge_code_handler callback

When Instagram demands a code, instagrapi raises a challenge through a callback you register on the client. The callback receives the username being authenticated and a ChallengeChoice enum value — currently SMS or EMAIL — telling you which channel Instagram picked. You return the 6-digit code as a string and instagrapi feeds it back through the verification flow.

The minimal interactive version is what you would use during development on your laptop:

from instagrapi import Client
from instagrapi.mixins.challenge import ChallengeChoice

def handler(username: str, choice: ChallengeChoice) -> str:
    # choice is SMS or EMAIL
    return input(f"Enter the {choice.name} code sent for {username}: ")

cl = Client()
cl.challenge_code_handler = handler
cl.login("USERNAME", "PASSWORD")

Two things are easy to miss. First, the callback is only invoked when a challenge actually fires — successful logins never call it, so don’t rely on side effects inside handler for normal flow. Second, you can return whatever string-shaped 6-digit code your channel produces; instagrapi does not care whether it came from a human at a keyboard or a webhook from your SMS provider. That’s the seam you exploit for headless deployment.

The callback also has a sibling, change_password_handler, that fires in the rarer case where Instagram demands a password reset as part of the challenge. You almost never want to automate that one — if Instagram is asking the account to rotate its password, treat it as a signal to investigate the account manually rather than papering over it. Leave change_password_handler unset and let instagrapi raise; catching the exception in your worker and paging an operator is the right behavior.

If the callback returns an empty string, returns None, or raises, instagrapi treats the challenge as failed and re-raises ChallengeRequired to the caller. That is the contract you build retries against — wrap cl.login(...) in try/except for ChallengeRequired, and on the second consecutive failure stop retrying and alert. Looping forever against a hard challenge is how accounts get permanently disabled.

Handling SMS programmatically

input() is fine on a developer laptop. On a production worker that runs in a container, on a schedule, with no TTY attached, it will hang forever. The fix is to swap the callback for one that fetches the code from a programmatic SMS source.

Twilio is the canonical example: you point your account’s recovery number at a Twilio-owned number you control, then poll the Twilio Messages API for the most recent inbound message and regex out the 6-digit code.

def twilio_handler(username, choice):
    # poll your Twilio inbox for the most recent message containing a 6-digit code
    code = poll_twilio_for_code()  # your impl
    return code

A few rules of thumb. Poll with backoff — start at one second, cap at fifteen, give up after sixty. Match \b\d{6}\b against the SMS body, not against the full message; Instagram’s templates change. And be wary of throwaway-number services (the ones that hand out a free temporary number for signup verification). Instagram fingerprints those number ranges and outright rejects codes delivered to them.

Handling TOTP / authenticator app

If your account has TOTP 2FA enabled (Google Authenticator, Authy, 1Password’s TOTP feature), instagrapi accepts the code as a kwarg on login() rather than going through the challenge callback. You compute the current 6-digit code from the seed and pass it through:

import pyotp

cl.login(
    "USERNAME",
    "PASSWORD",
    verification_code=pyotp.TOTP("YOUR_TOTP_SEED").now(),
)

The seed is the base32 string Instagram showed you (or the QR code’s secret parameter) when you enabled TOTP. pyotp.TOTP(seed).now() returns a fresh code each call — TOTP windows are 30 seconds, so compute it immediately before login(), not at module import time.

Treat the seed like a password. Don’t commit it to source, don’t ship it in a Docker image, don’t paste it in a Slack DM to your past self. Load it from an environment variable, AWS Secrets Manager, Vault, or whichever secrets store you already use. If the seed leaks, anyone with it can generate valid codes indefinitely; rotate by disabling TOTP in the Instagram app and re-enrolling.

Why session persistence is the real fix

Every section above treats challenges as something to react to. The deeper observation is that challenges are mostly a symptom of fingerprint drift between runs. Instantiate Client() cold, log in, do work, exit; next run, instantiate Client() cold again — and Instagram sees a different Android device every single time. That alone is enough to trigger challenges on a healthy account.

Persisting the session pins the device fingerprint and cookie jar across runs, so Instagram’s risk model sees the same client returning. In practice this single change eliminates roughly nine out of ten challenges — the remaining ones come from IP changes, volume spikes, or the account itself being stale.

cl.dump_settings("session.json")  # after first successful login
# next run:
cl = Client()
cl.load_settings("session.json")
cl.login("USERNAME", "PASSWORD")  # rarely re-prompts

Persist the file somewhere durable — Redis, S3, a database row keyed by account — once you outgrow a single script. The full pattern is in the session persistence guide. The point that catches most people: cl.load_settings() must be called before cl.login(), otherwise the device fingerprint is regenerated and you’ve defeated the entire mechanism.

Wrapping up

Wire up challenge_code_handler so headless deployments don’t hang, pass verification_code= for TOTP, and load every secret from a real secrets store rather than baking it into the image. Catch ChallengeRequired and bail after a single retry — looping against a hard challenge is how accounts get banned. The durable fix is upstream of all of this: reuse sessions and run through stable residential proxies. See the session persistence guide and the proxy setup guide.

Related guides

Frequently asked

What is challenge_required in Instagram?

challenge_required is Instagram's anti-fraud signal: the request looks suspicious (new IP, new device, unusual pattern) and the account must verify via SMS or email before continuing. instagrapi raises ChallengeRequired when this happens.

Why does Instagram trigger 2FA on every login with instagrapi?

Most often: changing device fingerprint between runs and not reusing the session. dump_settings()/load_settings() preserves the device fingerprint and cookie jar so Instagram sees the same client each time.

Can I automate SMS code retrieval?

Yes — programmatic SMS providers like Twilio (your own number) or short-code resellers can be wired into the challenge_code_handler callback. Be careful with throwaway-number services; Instagram blocks many of them.

What if I have TOTP enabled?

Pass `verification_code=` to login() with the current TOTP code, or set `totp_seed` so instagrapi can compute it. Don't store TOTP seeds in source — environment variables or a secrets manager.

Will running through a proxy stop challenges?

Stable, residential proxies reduce challenge rates dramatically. Datacenter IPs are flagged immediately. Rotating IP per request increases — not decreases — challenges; pin one IP per session.

Skip the infra?

Managed Instagram API — same endpoints, sessions and proxies handled.

Try HikerAPI → Full comparison
More from the team