Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
d7de240
fix: Update phonenumbers version to cover failing validation edgecases
bcbogdan Jan 23, 2026
87d8502
chore: better constrain the phonenumbers dep version
porcellus Jan 30, 2026
7ca9cd4
Merge remote-tracking branch 'origin/0.30' into update-phonenumbers
porcellus Jan 30, 2026
096c69a
chore: use dev core entrypoint for tests
porcellus Jan 30, 2026
9dff53b
fix: add missing dep for newer python versions
porcellus Jan 30, 2026
87c753c
fix: add data to rp_id field
porcellus Jan 30, 2026
8c464d1
chore: update changelog
porcellus Jan 30, 2026
9881bbd
fix: fix the error message/status code of the tokenExchange when the …
porcellus Jan 30, 2026
181cd0a
Merge pull request #623 from supertokens/update-phonenumbers
porcellus Jan 31, 2026
633ccae
fix(thirdparty): only redirect the Apple form_post callback to the co…
porcellus Oct 15, 2026
82022eb
fix(thirdparty): allow customising the Apple callback redirect check …
porcellus Oct 15, 2026
e3120aa
fix(dashboard): match admins write-restriction exemptions on the requ…
porcellus Oct 16, 2026
193bb6e
fix(accountlinking): require the primary user's matching email to be …
porcellus Oct 16, 2026
08e64aa
fix(session): require a string tId in v4+ access token payloads (F)
porcellus Oct 16, 2026
b4bf721
fix(thirdparty): remove dot segments from the Apple callback redirect…
porcellus Oct 16, 2026
a5a5320
fix(thirdparty): compare an internationalised configured origin in pu…
porcellus Oct 16, 2026
65e60c2
fix(thirdparty): answer a frontendRedirectURI that isn't valid unicod…
porcellus Oct 16, 2026
5416320
fix(thirdparty): document the always-rejected Apple callback redirect…
porcellus Oct 16, 2026
7d28512
fix(thirdparty): don't compare a configured origin that browsers conv…
porcellus Oct 16, 2026
766da37
fix(thirdparty): reject redirect targets with a % in the host in the …
porcellus Oct 16, 2026
1888e2c
docs(thirdparty): name the rejected control characters and the /. pat…
porcellus Oct 16, 2026
36cbb68
test(thirdparty): check that init rejects an is_valid_frontend_redire…
porcellus Oct 16, 2026
d5cf021
fix(auth): resolve the tenant before loading tenant specific config i…
porcellus Oct 16, 2026
add19c1
fix(thirdparty): call validate_access_token in the GitHub provider (T3)
porcellus Oct 16, 2026
ebcb70a
fix(webauthn): bind account recovery to the token's email and reset u…
porcellus Oct 16, 2026
bdd38e4
fix(dashboard): treat a blank api_key as unset and validate the Beare…
porcellus Oct 16, 2026
2e8533a
fix(thirdparty): keep static provider configs unchanged when building…
porcellus Oct 16, 2026
68bacff
fix(totp): require completed MFA to remove a device unless only an un…
porcellus Oct 16, 2026
f539034
fix(auth): match fake emails case-insensitively in is_fake_email (T9)
porcellus Oct 16, 2026
4849384
fix(passwordless): send the phone number as phoneNumber when revoking…
porcellus Oct 16, 2026
d344e7e
fix(dashboard): apply the admins check to the account unlink API (T11)
porcellus Oct 16, 2026
e8640bc
fix(session): reject access tokens whose stt is not the session token…
porcellus Oct 16, 2026
4426052
fix(thirdparty): require a matching hd for Google Workspaces sign in …
porcellus Oct 16, 2026
2746066
fix(session): limit JWKS refetches for unknown key ids to one per 500…
porcellus Oct 16, 2026
f6a404f
fix(session): fetch claim values for the session's tenant in validate…
porcellus Oct 16, 2026
dcd35c1
fix(multifactorauth): treat an empty tenant first_factors list as no …
porcellus Oct 16, 2026
b4c91e0
docs(session): explain how to repair sessions with a custom stt claim…
porcellus Oct 16, 2026
0ae3281
docs(thirdparty): add a release note for the Google Workspaces hd che…
porcellus Oct 16, 2026
18418ff
docs(session): say precisely where the protected stt claim is dropped…
porcellus Oct 16, 2026
cf83036
docs(session): add a release note for fetching claim values for the s…
porcellus Oct 16, 2026
f04633a
fix(session): clear the JWKS cache and its refetch time in SessionRec…
porcellus Oct 17, 2026
106a09b
chore: prepare release v0.30.4
porcellus Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [unreleased]

## [0.30.3] - 2025-08-28
## [0.30.4] - 2026-10-21

### Changed
- multifactorauth: a tenant whose `first_factors` is an empty list now allows no first factors, matching the documented meaning and the other SDKs. Sign in and sign up are rejected for such tenants, and with multi-factor auth enabled, their existing sessions that have no MFA claim yet are signed out. Current cores store tenants created through the API without `first_factors` with an empty list, so set `first_factors` explicitly (or to `None`) for those tenants, e.g. with `create_or_update_tenant` or the dashboard.
- thirdparty: the GitHub provider now validates the access token with GitHub (using `validate_access_token`) on every sign in, which needs the client's `client_secret`. Add it to the GitHub provider config, or set a custom `validate_access_token`.
- webauthn: account recovery now replaces the account's existing passkeys with the newly registered one and signs out its existing sessions if the account's email was not verified before the recovery. Accounts with a verified email keep their passkeys and sessions. Apps that don't use the EmailVerification recipe never have verified emails, so for them every account recovery resets the account's passkeys and sessions.
- dashboard: an empty or whitespace-only `api_key` is now treated as not set, so the dashboard uses its own sign in. In API key mode, the `Authorization` header must now be exactly `Bearer <api_key>`.
- auth: when there is a session, the sign in and sign up APIs now use its tenant (which already took precedence) before looking up third party providers and running the email, phone number and form field validators, so these now run with the tenant the user actually signs in to.
- auth: the sign in and sign up APIs (when they use an existing session to link a new login method) and the webauthn register credential API now check the session with the core, so revoked sessions are rejected. This adds one core call to those requests.
- totp: removing a device without completing multi-factor auth is now only allowed when the only device with that name is unverified and no verified device has the same name (compared case-insensitively).
- passwordless: `revoke_all_codes` with `phone_number` now revokes the codes of that phone number. Passing both `email` and `phone_number` now raises an error.
- dashboard: when `admins` is set, unlinking accounts through the dashboard now requires an admin, like the other dashboard APIs that change data.
- session: access tokens whose payload has an `stt` claim other than `0` are now rejected, and `stt` is now a protected access token payload claim: `create_new_session` drops an `stt` key from the payload you pass, claim validation drops it from its payload updates, and `merge_into_access_token_payload` removes an existing `stt` claim (it still stores an `stt` key in the update you pass to it, so don't pass one). If your code sets a custom access token claim named `stt`, rename it before upgrading. Existing sessions that carry such a claim are rejected with a 401 and can't be refreshed or signed out until they are repaired: repair them with `merge_into_access_token_payload(session_handle, {})`, which removes the now-protected claim, or revoke them.
- thirdparty: when the Google Workspaces provider is configured with a specific `hd` (anything other than `"*"`), a sign in without an id token (for example, when only an access token is sent to the sign in API) is now only accepted if Google's user info response has the same `hd`. Sign ins with an id token are checked against its `hd` claim, as before. If your app sends only an access token, you can send the id token as well to have that checked instead.
- session: when `validate_claims_for_session_handle` refetches claim values, it now fetches them for the session's own tenant, and it drops protected access token payload claims (such as `sub` or `tId`) from the resulting payload update before merging it into the session.

### Security
- thirdparty: the Apple form_post callback now only redirects to the configured `origin` / `website_domain` and answers invalid callback state with a 400. Apps whose `origin` depends on the request, or that redirect to other targets (e.g. another origin, https universal/app links or custom-scheme mobile deep links such as `myapp://callback`), can allow them with the new `is_valid_frontend_redirect_uri` thirdparty config option. `javascript:`, `data:` and similar schemes are always rejected. An `origin` with an internationalised domain name is converted to its punycode (`xn--`) form for this check, and http(s) redirect targets must use that form too. Python converts some domain names differently from browsers (e.g. ones with `ß` or `ς`), and the callback rejects redirects to such an `origin`, so set it in the punycode form browsers use (e.g. `https://xn--strae-oqa.example` for `https://straße.example`). The callback redirects to the target in a normalised form (e.g. with `.` and `..` path segments resolved) and passes that same form to `is_valid_frontend_redirect_uri`, so parse the URL there to check it instead of comparing strings.
- session: access token structure validation now requires a string `tId` claim for v4 and later access tokens.
- dashboard: the sign-out and analytics exemptions from the `admins` write restriction now match on the request path only, ignoring the query string.
- accountlinking: when `should_require_verification` is `True`, automatic account linking now also requires the matching email to be verified in the primary user (as it already did for phone numbers). Apps using `should_require_verification=True` now get no link in this case, which was always the documented intent.

## [0.30.3] - 2026-01-30
- Fixes webauthn MFA integration
- Adds missing `rp_id` field to Webauthn's `SignInOptionResponse`
- Updated phonenumbers dependency
- Explicitly added missing sniffio dependency
- Fix the OAuth2Provider `tokenExchange` error message when the refresh token is expired

## [0.30.2] - 2025-08-14
- Adds Webauthn user editing support to the Dashboard
Expand Down
10 changes: 5 additions & 5 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ services:
core:
# Uses `$SUPERTOKENS_CORE_VERSION` when available, else latest
image: supertokens/supertokens-dev-postgresql:${SUPERTOKENS_CORE_VERSION:-master}
# entrypoint: [
# "/usr/lib/supertokens/jre/bin/java",
# "-classpath", "/usr/lib/supertokens/core/*:/usr/lib/supertokens/plugin-interface/*:/usr/lib/supertokens/ee/*",
# "io.supertokens.Main", "/usr/lib/supertokens/", "DEV", "test_mode"
# ]
entrypoint: [
"/usr/lib/supertokens/jre/bin/java",
"-classpath", "/usr/lib/supertokens/core/*:/usr/lib/supertokens/plugin-interface/*:/usr/lib/supertokens/ee/*",
"io.supertokens.Main", "/usr/lib/supertokens/", "DEV", "test_mode"
]
ports:
# Uses `$SUPERTOKENS_CORE_PORT` when available, else 3567 for local port
- ${SUPERTOKENS_CORE_PORT:-3567}:3567
Expand Down
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

setup(
name="supertokens_python",
version="0.30.3",
version="0.30.4",
author="SuperTokens",
license="Apache 2.0",
author_email="team@supertokens.com",
Expand Down Expand Up @@ -121,13 +121,14 @@
"asgiref>=3.4.1,<4",
"typing_extensions>=4.1.1,<5.0.0",
"Deprecated<1.3.0",
"phonenumbers<9",
"phonenumbers>=9.0,<10.0",
"twilio<10",
"aiosmtplib>=1.1.6,<4.0.0",
"pkce<1.1.0",
"pyotp<3",
"python-dateutil<3",
"pydantic>=2.10.6,<3.0.0",
"sniffio>=1.1,<2.0.0",
],
python_requires=">=3.8",
include_package_data=True,
Expand Down
14 changes: 11 additions & 3 deletions supertokens_python/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,9 +969,14 @@ async def get_mfa_requirements_for_auth():


def is_fake_email(email: str) -> bool:
return email.endswith("@stfakeemail.supertokens.com") or email.endswith(
".fakeemail.com"
) # .fakeemail.com for older users
# emails are matched case-insensitively (after trimming), so we normalise the same way here
normalised_email = email.strip().lower()
return normalised_email.endswith(
(
"@stfakeemail.supertokens.com",
".fakeemail.com", # .fakeemail.com for older users
)
)


async def load_session_in_auth_api_if_needed(
Expand All @@ -984,6 +989,9 @@ async def load_session_in_auth_api_if_needed(
request,
session_required=should_try_linking_with_session_user is True,
override_global_claim_validators=lambda _, __, ___: [],
# The session can be used to link a new login method to the session user, so we make sure it
# hasn't been revoked (e.g.: during account recovery)
check_database=True,
user_context=user_context,
)
return None
2 changes: 1 addition & 1 deletion supertokens_python/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from __future__ import annotations

SUPPORTED_CDI_VERSIONS = ["5.3"]
VERSION = "0.30.3"
VERSION = "0.30.4"
TELEMETRY = "/telemetry"
USER_COUNT = "/users/count"
USER_DELETE = "/user/remove"
Expand Down
47 changes: 31 additions & 16 deletions supertokens_python/recipe/accountlinking/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -838,19 +838,11 @@ async def try_linking_by_account_info_or_create_primary_user(
status="NO_LINK", user=None
)

account_info_verified_in_prim_user = any(
(
input_user.login_methods[0].email is not None
and lm.has_same_email_as(input_user.login_methods[0].email)
account_info_verified_in_prim_user = (
has_verified_login_method_with_same_account_info(
primary_user_that_can_be_linked_to_the_input_user,
input_user.login_methods[0],
)
or (
input_user.login_methods[0].phone_number is not None
and lm.has_same_phone_number_as(
input_user.login_methods[0].phone_number
)
and lm.verified
)
for lm in primary_user_that_can_be_linked_to_the_input_user.login_methods
)
if should_do_account_linking.should_require_verification and (
not input_user.login_methods[0].verified
Expand Down Expand Up @@ -963,12 +955,15 @@ async def try_linking_by_account_info_or_create_primary_user(
status="NO_LINK", user=None
)

if (
should_do_account_linking.should_require_verification
and not input_user.login_methods[0].verified
if should_do_account_linking.should_require_verification and (
not input_user.login_methods[0].verified
or not has_verified_login_method_with_same_account_info(
create_primary_user_result.user,
input_user.login_methods[0],
)
):
log_debug_message(
"try_linking_by_account_info_or_create_primary_user: not linking because shouldRequireVerification is true but the login method is not verified"
"try_linking_by_account_info_or_create_primary_user: not linking because shouldRequireVerification is true but the login method is not verified in the new or the primary user"
)
return TryLinkingByAccountInfoOrCreatePrimaryUserResult(
status="NO_LINK", user=None
Expand Down Expand Up @@ -1049,3 +1044,23 @@ async def try_linking_by_account_info_or_create_primary_user(
raise Exception(
"This should never happen: ran out of retries for try_linking_by_account_info_or_create_primary_user"
)


def has_verified_login_method_with_same_account_info(
user: User, login_method: LoginMethod
) -> bool:
"""Checks if the user has a verified login method with the same email or phone number as the given login method"""
return any(
lm.verified
and (
(
login_method.email is not None
and lm.has_same_email_as(login_method.email)
)
or (
login_method.phone_number is not None
and lm.has_same_phone_number_as(login_method.phone_number)
)
)
for lm in user.login_methods
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from supertokens_python.recipe.thirdparty.provider import CommonProviderConfig, Provider
from supertokens_python.recipe.thirdparty.providers.config_utils import (
clone_provider_input,
find_and_create_provider_instance,
merge_providers_from_core_and_static,
)
Expand Down Expand Up @@ -77,6 +78,22 @@ def to_json(self) -> Dict[str, Any]:
return {"status": self.status}


def _with_additional_config(
client: ProviderClientConfig, additional_config: Dict[str, Any]
) -> ProviderClientConfig:
return ProviderClientConfig(
client_id=client.client_id,
client_secret=client.client_secret,
client_type=client.client_type,
scope=client.scope,
force_pkce=client.force_pkce,
additional_config={
**(client.additional_config or {}),
**additional_config,
},
)


async def get_third_party_config(
_: APIInterface,
tenant_id: str,
Expand Down Expand Up @@ -147,15 +164,15 @@ async def get_third_party_config(
providers_from_core[0].user_info_endpoint = None

if providers_from_core[0].clients is not None:
for existing_client in providers_from_core[0].clients:
existing_client.additional_config = {
**(existing_client.additional_config or {}),
**additional_config,
}
providers_from_core[0].clients = [
_with_additional_config(existing_client, additional_config)
for existing_client in providers_from_core[0].clients
]

# filter out other providers from static
# filter out other providers from static, and copy the remaining ones, so that
# the changes below do not change the static providers of the thirdparty recipe
static_providers = [
provider
clone_provider_input(provider)
for provider in static_providers
if provider.config.third_party_id == third_party_id
]
Expand Down Expand Up @@ -189,11 +206,10 @@ async def get_third_party_config(
static_providers[0].config.token_endpoint = None
static_providers[0].config.user_info_endpoint = None
if static_providers[0].config.clients is not None:
for existing_client in static_providers[0].config.clients:
existing_client.additional_config = {
**(existing_client.additional_config or {}),
**additional_config,
}
static_providers[0].config.clients = [
_with_additional_config(existing_client, additional_config)
for existing_client in static_providers[0].config.clients
]

merged_providers_from_core_and_static = merge_providers_from_core_and_static(
providers_from_core, static_providers, True
Expand Down Expand Up @@ -344,13 +360,12 @@ async def get_third_party_config(
)

json_response = resp
final_clients[0].additional_config.update(
{
"redirectURLs": json_response["redirectUrl"],
"boxyTenant": json_response["tenant"],
"boxyProduct": json_response["product"],
}
)
final_clients[0].additional_config = {
**final_clients[0].additional_config,
"redirectURLs": json_response["redirectUrl"],
"boxyTenant": json_response["tenant"],
"boxyProduct": json_response["product"],
}

provider_config = ProviderConfig(
third_party_id=third_party_id,
Expand Down
16 changes: 12 additions & 4 deletions supertokens_python/recipe/dashboard/recipe_implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from supertokens_python.recipe.dashboard.constants import (
DASHBOARD_ANALYTICS_API,
SIGN_OUT_API,
UNLINK_USER,
)
from supertokens_python.utils import log_debug_message, normalise_http_method

Expand All @@ -41,7 +42,7 @@ async def should_allow_access(
user_context: Dict[str, Any],
) -> bool:
# For cases where we're not using the API key, the JWT is being used; we allow their access by default
if config.api_key is None:
if not config.api_key:
auth_header_value = request.get_header("authorization")
if not auth_header_value:
return False
Expand All @@ -57,15 +58,22 @@ async def should_allow_access(
if session_verification_response.get("status") != "OK":
return False

# Match on the same normalised path the request is routed by (no query string)
request_path = NormalisedURLPath(
request.get_path()
).get_as_string_dangerous()

# For all non GET requests we also want to check if the
# user is allowed to perform this operation
if normalise_http_method(request.method()) != "get":
# The unlink API is a GET request, but it modifies the user, so we check it as well
is_get_request = normalise_http_method(request.method()) == "get"
if not is_get_request or request_path.endswith(UNLINK_USER):
# We dont want to block the analytics API
if request.get_original_url().endswith(DASHBOARD_ANALYTICS_API):
if request_path.endswith(DASHBOARD_ANALYTICS_API):
return True

# We do not want to block the sign out request
if request.get_original_url().endswith(SIGN_OUT_API):
if request_path.endswith(SIGN_OUT_API):
return True

admins = config.admins
Expand Down
26 changes: 21 additions & 5 deletions supertokens_python/recipe/dashboard/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# under the License.
from __future__ import annotations

import hmac
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union

from typing_extensions import Literal
Expand Down Expand Up @@ -117,6 +118,9 @@ def validate_and_normalise_user_input(
if override is None:
override = InputOverrideConfig()

# An empty or whitespace-only api key means no api key, i.e. dashboard sessions are used
api_key = api_key if api_key is not None and api_key.strip() != "" else None

if api_key is not None and admins is not None:
log_debug_message(
"User Dashboard: Providing 'admins' has no effect when using an api key."
Expand All @@ -131,7 +135,7 @@ def validate_and_normalise_user_input(
functions=override.functions,
apis=override.apis,
),
"api-key" if api_key else "email-password",
"api-key" if api_key is not None else "email-password",
)


Expand Down Expand Up @@ -273,12 +277,24 @@ async def _get_user_for_recipe_id(
async def validate_api_key(
req: BaseRequest, config: DashboardConfig, _user_context: Dict[str, Any]
) -> bool:
if not config.api_key:
return False

api_key_header_value = req.get_header("authorization")
if not api_key_header_value:
# We receive the api key as `Bearer API_KEY`, this retrieves just the key
bearer_prefix = "Bearer "
if api_key_header_value is None or not api_key_header_value.startswith(
bearer_prefix
):
return False
# We receieve the api key as `Bearer API_KEY`, this retrieves just the key
api_key_header_value = api_key_header_value.split(" ")[1]
return api_key_header_value == config.api_key

api_key_header_value = api_key_header_value[len(bearer_prefix) :]
if api_key_header_value == "":
return False

return hmac.compare_digest(
api_key_header_value.encode("utf-8"), config.api_key.encode("utf-8")
)


def get_api_path_with_dashboard_base(path: str) -> str:
Expand Down
Loading
Loading