2025, Oct 21 09:00

Supabase password reset in Python: avoid 'Token has expired or is invalid' by verifying token_hash with verify_otp

Learn how to fix Supabase password reset failures in Python: stop the 'Token has expired or is invalid' error by using token_hash with verify_otp in scripts.

Resetting a Supabase user password from a Python script can look straightforward, yet it’s easy to get blocked by the dreaded “Token has expired or is invalid”. The trap is subtle: using the token from the reset link itself for verification doesn’t work in this flow. The fix is to use the token_hash in your email template and pass that into verify_otp.

Problem setup

The script initializes a Supabase client, triggers a password reset email, extracts the token from the link, verifies it, and then updates the password. The link arrives in the shape of https://ABC.supabase.co/auth/v1/verify?token=XYZ&type=recovery&redirect_to=http://localhost:3000, the token is parsed out correctly, yet verify_otp raises “Token has expired or is invalid”.

Reproducible example

The following Python code demonstrates the flow end-to-end, including extracting the token from the received URL and attempting to verify it as a recovery OTP.

import os
from supabase import create_client, Client
from dotenv import load_dotenv

load_dotenv()

# initialize Supabase client from environment
# SUPABASE_URL and SUPABASE_KEY are expected to be present in the environment

def build_client() -> Client:
    api_url: str = os.environ["SUPABASE_URL"]
    anon_key: str = os.environ["SUPABASE_KEY"]
    sb: Client = create_client(api_url, anon_key)
    return sb


def dispatch_reset_link(sb: Client):
    try:
        user_email = input("Please insert your email\n")
        resp = sb.auth.reset_password_for_email(
            user_email,
        )
        print("RESP=")
        print(resp)
        print("\nIf your email is already registered, you will receive a password reset email! Please check your inbox.\n")
    except Exception as exc:
        print("Failed to send reset email: ", str(exc), "\n")


def change_secret(sb: Client):
    try:
        link_raw = input("Please paste the link you received via email\n")
        email_input = input("Please insert your email\n")
        new_pass = input("Please insert your new password\n")

        # extract token from the URL
        parsed_token = link_raw.split("token=")[1].split("&type")[0]
        print("TOKEN = ", parsed_token)

        if not parsed_token:
            raise ValueError("Invalid link. No token found.")

        # verify recovery OTP with the token from the URL
        resp_verify = sb.auth.verify_otp({
            "email": email_input,
            "type": "recovery",
            "token": parsed_token,
        })
        print("RESP_1=")
        print(resp_verify)
        print("\n")

        # update the password for the authenticated recovery session
        resp_update = sb.auth.update_user({
            "password": new_pass
        })
        print("RESP_2=")
        print(resp_update)
        print("\n")

        print("Password updated successfully\n")

    except Exception as exc:
        print("Failed to update password: ", str(exc), "\n")


sb_client: Client = build_client()
print(sb_client)
print("\n\n")

dispatch_reset_link(sb_client)
change_secret(sb_client)

Why the verification fails

The recovery URL contains a token parameter, but using that value directly in verify_otp leads to “Token has expired or is invalid”. The correct input for verification in this flow is the token_hash, not the token from the link. If you try to pass the URL token to verify_otp, the verification will fail and you will not reach the password update step.

The fix

First, adjust the email template so it includes the token hash. Then, read that token hash and feed it into verify_otp using the token_hash field.

<h2>Password Reset</h2>

<p>We're sorry to hear that you're having trouble accessing your account. To reset your password and regain access, please paste the token hash below into your terminal:</p>

<p>Token Hash: {{ .TokenHash }}</p>

In your Python code, ingest and use the token hash with type set to recovery.

resp_1 = sb_client.auth.verify_otp({
  "type": "recovery",
  "token_hash": token_input,
})

Putting this into the earlier script, prompt for the token hash instead of parsing the URL token, and keep the password update call as-is.

import os
from supabase import create_client, Client
from dotenv import load_dotenv

load_dotenv()


def build_client() -> Client:
    api_url: str = os.environ["SUPABASE_URL"]
    anon_key: str = os.environ["SUPABASE_KEY"]
    sb: Client = create_client(api_url, anon_key)
    return sb


def dispatch_reset_link(sb: Client):
    try:
        user_email = input("Please insert your email\n")
        resp = sb.auth.reset_password_for_email(user_email)
        print("RESP=")
        print(resp)
        print("\nIf your email is already registered, you will receive a password reset email! Please check your inbox.\n")
    except Exception as exc:
        print("Failed to send reset email: ", str(exc), "\n")


def change_secret(sb: Client):
    try:
        hashed_token = input("Please paste the token hash you received via email\n")
        new_pass = input("Please insert your new password\n")

        # verify recovery using token_hash
        resp_verify = sb.auth.verify_otp({
            "type": "recovery",
            "token_hash": hashed_token,
        })
        print("RESP_1=")
        print(resp_verify)
        print("\n")

        # update the password
        resp_update = sb.auth.update_user({
            "password": new_pass
        })
        print("RESP_2=")
        print(resp_update)
        print("\n")

        print("Password updated successfully\n")

    except Exception as exc:
        print("Failed to update password: ", str(exc), "\n")


sb_client: Client = build_client()
print(sb_client)
print("\n\n")

dispatch_reset_link(sb_client)
change_secret(sb_client)

Why this matters

When building password recovery flows, small mismatches in what the backend expects versus what you pass in can produce confusing errors and failed resets. Using token_hash aligns the verification step with the actual recovery token you’re instructed to surface in the email, which makes the process reliable for both CLI-style scripts and custom UIs that request input from the user.

Takeaways

If your password reset flow is failing with “Token has expired or is invalid” after parsing the token from the recovery URL, stop passing token and switch to token_hash. Expose the token hash in the password reset email and verify it with verify_otp using type set to recovery. After a successful verification, proceed with update_user to set the new password.

The article is based on a question from StackOverflow by eljamba and an answer by Andrew Smith.