From 81e4a639afe6872d3a9b41eaedb91052066e138a Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 19 Nov 2025 22:01:06 +0100 Subject: [PATCH 01/13] Forbid passing str to Security `scopes` parameter. Add descriptive error message --- fastapi/param_functions.py | 36 +++++++++++++++++++++++-- fastapi/params.py | 21 +++++++++++++-- tests/test_security_scopes_parameter.py | 26 ++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 tests/test_security_scopes_parameter.py diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index e32f75593..0e4a8b034 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -1,8 +1,19 @@ -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import ( + Any, + Callable, + Dict, + FrozenSet, + List, + Optional, + Set, + Tuple, + Union, +) from annotated_doc import Doc from fastapi import params from fastapi._compat import Undefined +from fastapi.exceptions import FastAPIError from fastapi.openapi.models import Example from typing_extensions import Annotated, Literal, deprecated @@ -2312,7 +2323,14 @@ def Security( # noqa: N802 ] = None, *, scopes: Annotated[ - Optional[Sequence[str]], + Optional[ + Union[ + List[str], + Tuple[str, ...], + Set[str], + FrozenSet[str], + ] + ], Doc( """ OAuth2 scopes required for the *path operation* that uses this Security @@ -2378,4 +2396,18 @@ def Security( # noqa: N802 return [{"item_id": "Foo", "owner": current_user.username}] ``` """ + + if isinstance(scopes, str): + if scopes in ("function", "request"): + raise FastAPIError( + "Invalid value for `scopes` parameter in Security(). " + "You probably meant to use the `scope` parameter instead of `scopes`. " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string." + ) + raise FastAPIError( + "Invalid value for `scopes` parameter in Security(). " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " + "Wrap it in a list: scopes=['your_scope'] instead of scopes='your_scope'." + ) + return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) diff --git a/fastapi/params.py b/fastapi/params.py index 6a58d5808..f92e2025c 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -1,7 +1,17 @@ import warnings from dataclasses import dataclass from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Sequence, Union +from typing import ( + Any, + Callable, + Dict, + FrozenSet, + List, + Optional, + Set, + Tuple, + Union, +) from fastapi.openapi.models import Example from pydantic.fields import FieldInfo @@ -771,4 +781,11 @@ class Depends: @dataclass class Security(Depends): - scopes: Optional[Sequence[str]] = None + scopes: Optional[ + Union[ + List[str], + Tuple[str, ...], + Set[str], + FrozenSet[str], + ] + ] = None diff --git a/tests/test_security_scopes_parameter.py b/tests/test_security_scopes_parameter.py new file mode 100644 index 000000000..cd043bcd1 --- /dev/null +++ b/tests/test_security_scopes_parameter.py @@ -0,0 +1,26 @@ +import pytest +from fastapi import Security +from fastapi.exceptions import FastAPIError + + +def test_pass_single_str(): + with pytest.raises(FastAPIError) as exc_info: + Security(dependency=lambda: None, scopes="admin") + + assert str(exc_info.value) == ( + "Invalid value for `scopes` parameter in Security(). " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " + "Wrap it in a list: scopes=['your_scope'] instead of scopes='your_scope'." + ) + + +@pytest.mark.parametrize("value", ["function", "request"]) +def test_pass_scope_instead_of_scopes(value: str): + with pytest.raises(FastAPIError) as exc_info: + Security(dependency=lambda: None, scopes=value) + + assert str(exc_info.value) == ( + "Invalid value for `scopes` parameter in Security(). " + "You probably meant to use the `scope` parameter instead of `scopes`. " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string." + ) From 73fd05ccf0f365850c5d530300d434c8ee2d0b54 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:37:35 +0100 Subject: [PATCH 02/13] Add `oauth_scopes`, deprecate `scopes` --- .../docs/advanced/security/oauth2-scopes.md | 20 +++-- docs_src/security/tutorial005.py | 4 +- docs_src/security/tutorial005_an.py | 6 +- docs_src/security/tutorial005_an_py310.py | 6 +- docs_src/security/tutorial005_an_py39.py | 6 +- docs_src/security/tutorial005_py310.py | 4 +- docs_src/security/tutorial005_py39.py | 4 +- fastapi/dependencies/utils.py | 8 +- fastapi/param_functions.py | 75 +++++++++++++++---- fastapi/params.py | 2 +- tests/test_dependency_cache.py | 4 +- tests/test_dependency_paramless.py | 4 +- tests/test_dependency_security_overrides.py | 2 +- 13 files changed, 105 insertions(+), 40 deletions(-) diff --git a/docs/en/docs/advanced/security/oauth2-scopes.md b/docs/en/docs/advanced/security/oauth2-scopes.md index 67c927cd0..1d5acd7f0 100644 --- a/docs/en/docs/advanced/security/oauth2-scopes.md +++ b/docs/en/docs/advanced/security/oauth2-scopes.md @@ -106,7 +106,17 @@ Now we declare that the *path operation* for `/users/me/items/` requires the sco For this, we import and use `Security` from `fastapi`. -You can use `Security` to declare dependencies (just like `Depends`), but `Security` also receives a parameter `scopes` with a list of scopes (strings). +You can use `Security` to declare dependencies (just like `Depends`), but `Security` also receives a parameter `oauth_scopes` with a list of scopes (strings). + +/// note + +Before version 0.121.4, the name of this parameter was `scopes`. + +Since FastAPI 0.121.4, the `scopes` parameter has been deprecated in favor of `oauth_scopes` +to avoid confusing it with the `scope` parameter, which is used to specify when the exit code +of dependencies with `yield` should run. + +/// In this case, we pass a dependency function `get_current_active_user` to `Security` (the same way we would do with `Depends`). @@ -124,7 +134,7 @@ We are doing it here to demonstrate how **FastAPI** handles scopes declared at d /// -{* ../../docs_src/security/tutorial005_an_py310.py hl[5,141,172] *} +{* ../../docs_src/security/tutorial005_an_py310.py hl[5,141,173] *} /// info | Technical Details @@ -213,13 +223,13 @@ Here's how the hierarchy of dependencies and scopes looks like: * This `security_scopes` parameter has a property `scopes` with a `list` containing all these scopes declared above, so: * `security_scopes.scopes` will contain `["me", "items"]` for the *path operation* `read_own_items`. * `security_scopes.scopes` will contain `["me"]` for the *path operation* `read_users_me`, because it is declared in the dependency `get_current_active_user`. - * `security_scopes.scopes` will contain `[]` (nothing) for the *path operation* `read_system_status`, because it didn't declare any `Security` with `scopes`, and its dependency, `get_current_user`, doesn't declare any `scopes` either. + * `security_scopes.scopes` will contain `[]` (nothing) for the *path operation* `read_system_status`, because it didn't declare any `Security` with `oauth_scopes`, and its dependency, `get_current_user`, doesn't declare any `oauth_scopes` either. /// tip The important and "magic" thing here is that `get_current_user` will have a different list of `scopes` to check for each *path operation*. -All depending on the `scopes` declared in each *path operation* and each dependency in the dependency tree for that specific *path operation*. +All depending on the `oauth_scopes` declared in each *path operation* and each dependency in the dependency tree for that specific *path operation*. /// @@ -271,4 +281,4 @@ But in the end, they are implementing the same OAuth2 standard. ## `Security` in decorator `dependencies` { #security-in-decorator-dependencies } -The same way you can define a `list` of `Depends` in the decorator's `dependencies` parameter (as explained in [Dependencies in path operation decorators](../../tutorial/dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), you could also use `Security` with `scopes` there. +The same way you can define a `list` of `Depends` in the decorator's `dependencies` parameter (as explained in [Dependencies in path operation decorators](../../tutorial/dependencies/dependencies-in-path-operation-decorators.md){.internal-link target=_blank}), you could also use `Security` with `oauth_scopes` there. diff --git a/docs_src/security/tutorial005.py b/docs_src/security/tutorial005.py index fdd73bcd8..256101fa4 100644 --- a/docs_src/security/tutorial005.py +++ b/docs_src/security/tutorial005.py @@ -138,7 +138,7 @@ async def get_current_user( async def get_current_active_user( - current_user: User = Security(get_current_user, scopes=["me"]), + current_user: User = Security(get_current_user, oauth_scopes=["me"]), ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -167,7 +167,7 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): @app.get("/users/me/items/") async def read_own_items( - current_user: User = Security(get_current_active_user, scopes=["items"]), + current_user: User = Security(get_current_active_user, oauth_scopes=["items"]), ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/docs_src/security/tutorial005_an.py b/docs_src/security/tutorial005_an.py index e1d7b4f62..513049331 100644 --- a/docs_src/security/tutorial005_an.py +++ b/docs_src/security/tutorial005_an.py @@ -139,7 +139,7 @@ async def get_current_user( async def get_current_active_user( - current_user: Annotated[User, Security(get_current_user, scopes=["me"])], + current_user: Annotated[User, Security(get_current_user, oauth_scopes=["me"])], ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -170,7 +170,9 @@ async def read_users_me( @app.get("/users/me/items/") async def read_own_items( - current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])], + current_user: Annotated[ + User, Security(get_current_active_user, oauth_scopes=["items"]) + ], ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/docs_src/security/tutorial005_an_py310.py b/docs_src/security/tutorial005_an_py310.py index df55951c0..f28ad350f 100644 --- a/docs_src/security/tutorial005_an_py310.py +++ b/docs_src/security/tutorial005_an_py310.py @@ -138,7 +138,7 @@ async def get_current_user( async def get_current_active_user( - current_user: Annotated[User, Security(get_current_user, scopes=["me"])], + current_user: Annotated[User, Security(get_current_user, oauth_scopes=["me"])], ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -169,7 +169,9 @@ async def read_users_me( @app.get("/users/me/items/") async def read_own_items( - current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])], + current_user: Annotated[ + User, Security(get_current_active_user, oauth_scopes=["items"]) + ], ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/docs_src/security/tutorial005_an_py39.py b/docs_src/security/tutorial005_an_py39.py index 983c1c22c..190547caf 100644 --- a/docs_src/security/tutorial005_an_py39.py +++ b/docs_src/security/tutorial005_an_py39.py @@ -138,7 +138,7 @@ async def get_current_user( async def get_current_active_user( - current_user: Annotated[User, Security(get_current_user, scopes=["me"])], + current_user: Annotated[User, Security(get_current_user, oauth_scopes=["me"])], ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -169,7 +169,9 @@ async def read_users_me( @app.get("/users/me/items/") async def read_own_items( - current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])], + current_user: Annotated[ + User, Security(get_current_active_user, oauth_scopes=["items"]) + ], ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/docs_src/security/tutorial005_py310.py b/docs_src/security/tutorial005_py310.py index d08e2c59f..c3e89e932 100644 --- a/docs_src/security/tutorial005_py310.py +++ b/docs_src/security/tutorial005_py310.py @@ -137,7 +137,7 @@ async def get_current_user( async def get_current_active_user( - current_user: User = Security(get_current_user, scopes=["me"]), + current_user: User = Security(get_current_user, oauth_scopes=["me"]), ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -166,7 +166,7 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): @app.get("/users/me/items/") async def read_own_items( - current_user: User = Security(get_current_active_user, scopes=["items"]), + current_user: User = Security(get_current_active_user, oauth_scopes=["items"]), ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/docs_src/security/tutorial005_py39.py b/docs_src/security/tutorial005_py39.py index 5bde47ef4..4a99de613 100644 --- a/docs_src/security/tutorial005_py39.py +++ b/docs_src/security/tutorial005_py39.py @@ -138,7 +138,7 @@ async def get_current_user( async def get_current_active_user( - current_user: User = Security(get_current_user, scopes=["me"]), + current_user: User = Security(get_current_user, oauth_scopes=["me"]), ): if current_user.disabled: raise HTTPException(status_code=400, detail="Inactive user") @@ -167,7 +167,7 @@ async def read_users_me(current_user: User = Depends(get_current_active_user)): @app.get("/users/me/items/") async def read_own_items( - current_user: User = Security(get_current_active_user, scopes=["items"]), + current_user: User = Security(get_current_active_user, oauth_scopes=["items"]), ): return [{"item_id": "Foo", "owner": current_user.username}] diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 4b69e39a1..06b0a70aa 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -126,8 +126,8 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De "A parameter-less dependency must have a callable dependency" ) use_security_scopes: List[str] = [] - if isinstance(depends, params.Security) and depends.scopes: - use_security_scopes.extend(depends.scopes) + if isinstance(depends, params.Security) and depends.oauth_scopes: + use_security_scopes.extend(depends.oauth_scopes) return get_dependant( path=path, call=depends.dependency, @@ -276,8 +276,8 @@ def get_dependant( ) use_security_scopes = security_scopes or [] if isinstance(param_details.depends, params.Security): - if param_details.depends.scopes: - use_security_scopes.extend(param_details.depends.scopes) + if param_details.depends.oauth_scopes: + use_security_scopes.extend(param_details.depends.oauth_scopes) sub_dependant = get_dependant( path=path, call=param_details.depends.dependency, diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 0e4a8b034..2ec498d0f 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -1,3 +1,4 @@ +import warnings from typing import ( Any, Callable, @@ -2322,6 +2323,29 @@ def Security( # noqa: N802 ), ] = None, *, + oauth_scopes: Annotated[ + Optional[ + Union[ + List[str], + Tuple[str, ...], + Set[str], + FrozenSet[str], + ] + ], + Doc( + """ + OAuth2 scopes required for the *path operation* that uses this Security + dependency. + + The term "scope" comes from the OAuth2 specification, it seems to be + intentionally vague and interpretable. It normally refers to permissions, + in cases to roles. + + These scopes are integrated with OpenAPI (and the API docs at `/docs`). + So they are visible in the OpenAPI specification. + """ + ), + ] = None, scopes: Annotated[ Optional[ Union[ @@ -2342,6 +2366,26 @@ def Security( # noqa: N802 These scopes are integrated with OpenAPI (and the API docs at `/docs`). So they are visible in the OpenAPI specification. + + This parameter is deprecated in favor of `oauth_scopes`. + """ + ), + deprecated( + """ + In order to avoid confusion with `scope` parameter, the `scopes` parameter + is deprecated in favor of `oauth_scopes`. + + To specify dependency scope for dependencies with `yield` use `scope` parameter: + + ```python + Security(dependency_fn, scope="function") + ``` + + To specify OAuth2 scopes use `oauth_scopes` parameter: + + ```python + Security(dependency_fn, oauth_scopes=["items", "users"]) + ``` ) """ ), @@ -2391,23 +2435,28 @@ def Security( # noqa: N802 @app.get("/users/me/items/") async def read_own_items( - current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])] + current_user: Annotated[ + User, Security(get_current_active_user, oauth_scopes=["items"]) + ] ): return [{"item_id": "Foo", "owner": current_user.username}] ``` """ - if isinstance(scopes, str): - if scopes in ("function", "request"): - raise FastAPIError( - "Invalid value for `scopes` parameter in Security(). " - "You probably meant to use the `scope` parameter instead of `scopes`. " - "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string." - ) - raise FastAPIError( - "Invalid value for `scopes` parameter in Security(). " - "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " - "Wrap it in a list: scopes=['your_scope'] instead of scopes='your_scope'." + if scopes is not None: + warnings.warn( + ( + "The 'scopes' parameter in Security() is deprecated in favor of " + "'oauth_scopes' in order to avoid confusion with 'scope' parameter." + ), + DeprecationWarning, + stacklevel=2, ) - return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) + oauth_scopes = oauth_scopes or scopes + + return params.Security( + dependency=dependency, + oauth_scopes=oauth_scopes, + use_cache=use_cache, + ) diff --git a/fastapi/params.py b/fastapi/params.py index f92e2025c..3097fd6fd 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -781,7 +781,7 @@ class Depends: @dataclass class Security(Depends): - scopes: Optional[ + oauth_scopes: Optional[ Union[ List[str], Tuple[str, ...], diff --git a/tests/test_dependency_cache.py b/tests/test_dependency_cache.py index 08fb9b74f..d3f43ce39 100644 --- a/tests/test_dependency_cache.py +++ b/tests/test_dependency_cache.py @@ -38,8 +38,8 @@ async def get_sub_counter_no_cache( @app.get("/scope-counter") async def get_scope_counter( count: int = Security(dep_counter), - scope_count_1: int = Security(dep_counter, scopes=["scope"]), - scope_count_2: int = Security(dep_counter, scopes=["scope"]), + scope_count_1: int = Security(dep_counter, oauth_scopes=["scope"]), + scope_count_2: int = Security(dep_counter, oauth_scopes=["scope"]), ): return { "counter": count, diff --git a/tests/test_dependency_paramless.py b/tests/test_dependency_paramless.py index 9c3cc3878..225db29e5 100644 --- a/tests/test_dependency_paramless.py +++ b/tests/test_dependency_paramless.py @@ -28,14 +28,14 @@ def process_auth( @app.get("/get-credentials") def get_credentials( - credentials: Annotated[dict, Security(process_auth, scopes=["a", "b"])], + credentials: Annotated[dict, Security(process_auth, oauth_scopes=["a", "b"])], ): return credentials @app.get( "/parameterless-with-scopes", - dependencies=[Security(process_auth, scopes=["a", "b"])], + dependencies=[Security(process_auth, oauth_scopes=["a", "b"])], ) def get_parameterless_with_scopes(): return {"status": "ok"} diff --git a/tests/test_dependency_security_overrides.py b/tests/test_dependency_security_overrides.py index b89d82db4..efc5a8dd0 100644 --- a/tests/test_dependency_security_overrides.py +++ b/tests/test_dependency_security_overrides.py @@ -25,7 +25,7 @@ def get_data_override(): @app.get("/user") def read_user( - user_data: Tuple[str, List[str]] = Security(get_user, scopes=["foo", "bar"]), + user_data: Tuple[str, List[str]] = Security(get_user, oauth_scopes=["foo", "bar"]), data: List[int] = Depends(get_data), ): return {"user": user_data[0], "scopes": user_data[1], "data": data} From ed8040aa98a33a3295e51dffb1d61339b9d7ffd6 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:44:37 +0100 Subject: [PATCH 03/13] Handle mistakes when wrong value is passed to `scopes` or `oauth_scopes` --- fastapi/param_functions.py | 21 +++++++++++++++++++ tests/test_security_scopes_parameter.py | 27 +++++++++++++++++-------- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 2ec498d0f..f9389e0cd 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -2453,8 +2453,29 @@ def Security( # noqa: N802 stacklevel=2, ) + # Handle case when `scopes="function"` is mistakenly used instead of `scope="function"` + if isinstance(scopes, str) and (scopes in ("function", "request")): + raise FastAPIError( + "Invalid value for the 'scopes' parameter in Security(). " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received " + "a single string. " + f'Did you mean to use scope="{scopes}" to specify when the exit code ' + "of dependencies with yield should run? " + ) + + oauth_scopes_param = "oauth_scopes" if (oauth_scopes is not None) else "scopes" oauth_scopes = oauth_scopes or scopes + # Handle case when single string is passed to `scopes` or `oauth_scopes` instead of + # a list of strings + if isinstance(oauth_scopes, str): + raise FastAPIError( + f"Invalid value for the '{oauth_scopes_param}' parameter in Security(). " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a " + "single string. Wrap it in a list: oauth_scopes=['your_scope'] instead of " + "oauth_scopes='your_scope'." + ) + return params.Security( dependency=dependency, oauth_scopes=oauth_scopes, diff --git a/tests/test_security_scopes_parameter.py b/tests/test_security_scopes_parameter.py index cd043bcd1..d893bbbf2 100644 --- a/tests/test_security_scopes_parameter.py +++ b/tests/test_security_scopes_parameter.py @@ -3,24 +3,35 @@ from fastapi import Security from fastapi.exceptions import FastAPIError -def test_pass_single_str(): +@pytest.mark.parametrize("parameter_name", ["scopes", "oauth_scopes"]) +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_pass_single_str(parameter_name: str): + """ + Test passing single string instead of list of strings to `scopes` or `oauth_scopes`. + """ + with pytest.raises(FastAPIError) as exc_info: - Security(dependency=lambda: None, scopes="admin") + Security(dependency=lambda: None, **{parameter_name: "admin"}) assert str(exc_info.value) == ( - "Invalid value for `scopes` parameter in Security(). " + f"Invalid value for the '{parameter_name}' parameter in Security(). " "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " - "Wrap it in a list: scopes=['your_scope'] instead of scopes='your_scope'." + "Wrap it in a list: oauth_scopes=['your_scope'] instead of oauth_scopes='your_scope'." ) @pytest.mark.parametrize("value", ["function", "request"]) -def test_pass_scope_instead_of_scopes(value: str): +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_pass_scope_as_scopes(value: str): + """ + Test passing `scopes="function"` instead of `scope="function"` to `Security`. + """ + with pytest.raises(FastAPIError) as exc_info: Security(dependency=lambda: None, scopes=value) assert str(exc_info.value) == ( - "Invalid value for `scopes` parameter in Security(). " - "You probably meant to use the `scope` parameter instead of `scopes`. " - "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string." + "Invalid value for the 'scopes' parameter in Security(). " + "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " + f'Did you mean to use scope="{value}" to specify when the exit code of dependencies with yield should run? ' ) From 7dcfecf6064f721efdbf5231df0e963a7125e6b3 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:45:45 +0100 Subject: [PATCH 04/13] Add `scope` parameter to `Security` --- fastapi/param_functions.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index f9389e0cd..eee7542fa 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -2386,7 +2386,26 @@ def Security( # noqa: N802 ```python Security(dependency_fn, oauth_scopes=["items", "users"]) ``` - ) + """ + ), + ] = None, + scope: Annotated[ + Union[Literal["function", "request"], None], + Doc( + """ + Mainly for dependencies with `yield`, define when the dependency function + should start (the code before `yield`) and when it should end (the code + after `yield`). + + * `"function"`: start the dependency before the *path operation function* + that handles the request, end the dependency after the *path operation + function* ends, but **before** the response is sent back to the client. + So, the dependency function will be executed **around** the *path operation + **function***. + * `"request"`: start the dependency before the *path operation function* + that handles the request (similar to when using `"function"`), but end + **after** the response is sent back to the client. So, the dependency + function will be executed **around** the **request** and response cycle. """ ), ] = None, @@ -2480,4 +2499,5 @@ def Security( # noqa: N802 dependency=dependency, oauth_scopes=oauth_scopes, use_cache=use_cache, + scope=scope, ) From ab2a92e0a2330be6cb2c14d80404eea62ddb28d6 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:47:48 +0100 Subject: [PATCH 05/13] Handle mistakes when wrong value is passed to `scope` --- fastapi/param_functions.py | 18 ++++++++++++++ tests/test_security_scopes_parameter.py | 32 ++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index eee7542fa..eae6f0ae3 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -2307,6 +2307,16 @@ def Depends( # noqa: N802 return commons ``` """ + + # Handle case when `scope` parameter value is invalid + if scope not in ("function", "request", None): + raise FastAPIError( + "Invalid value for 'scope' parameter in Depends(). " + "Expected 'function', 'request', or None. " + f'Did you mean to use Security(dependency_fn, oauth_scopes="{scope}") ' + "to specify OAuth2 scopes instead?" + ) + return params.Depends(dependency=dependency, use_cache=use_cache, scope=scope) @@ -2495,6 +2505,14 @@ def Security( # noqa: N802 "oauth_scopes='your_scope'." ) + # Handle case when `scope` parameter value is invalid + if scope not in ("function", "request", None): + raise FastAPIError( + "Invalid value for 'scope' parameter in Security(). " + "Expected 'function', 'request', or None. " + f'Did you mean oauth_scopes="{scope}" to specify OAuth2 scopes instead?' + ) + return params.Security( dependency=dependency, oauth_scopes=oauth_scopes, diff --git a/tests/test_security_scopes_parameter.py b/tests/test_security_scopes_parameter.py index d893bbbf2..66ca70a41 100644 --- a/tests/test_security_scopes_parameter.py +++ b/tests/test_security_scopes_parameter.py @@ -1,5 +1,5 @@ import pytest -from fastapi import Security +from fastapi import Depends, Security from fastapi.exceptions import FastAPIError @@ -35,3 +35,33 @@ def test_pass_scope_as_scopes(value: str): "Expected a sequence of strings (e.g., ['admin', 'user']), but received a single string. " f'Did you mean to use scope="{value}" to specify when the exit code of dependencies with yield should run? ' ) + + +def test_pass_invalid_scope_value_to_security(): + """ + Test passing invalid value to `scope` parameter in `Security`. + """ + + with pytest.raises(FastAPIError) as exc_info: + Security(dependency=lambda: None, scope="invalid_scope") + + assert str(exc_info.value) == ( + "Invalid value for 'scope' parameter in Security(). " + "Expected 'function', 'request', or None. " + 'Did you mean oauth_scopes="invalid_scope" to specify OAuth2 scopes instead?' + ) + + +def test_pass_invalid_scope_value_to_depends(): + """ + Test passing invalid value to `scope` parameter in `Depends`. + """ + + with pytest.raises(FastAPIError) as exc_info: + Depends(dependency=lambda: None, scope="invalid_scope") + + assert str(exc_info.value) == ( + "Invalid value for 'scope' parameter in Depends(). " + "Expected 'function', 'request', or None. " + 'Did you mean to use Security(dependency_fn, oauth_scopes="invalid_scope") to specify OAuth2 scopes instead?' + ) From 8e1f870dcba8bb6188db3d8f059e8421c93ca8c0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:48:38 +0100 Subject: [PATCH 06/13] Add test for deprecation warning when `scopes` is used --- tests/test_security_scopes_parameter.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_security_scopes_parameter.py b/tests/test_security_scopes_parameter.py index 66ca70a41..62ed6771d 100644 --- a/tests/test_security_scopes_parameter.py +++ b/tests/test_security_scopes_parameter.py @@ -3,6 +3,23 @@ from fastapi import Depends, Security from fastapi.exceptions import FastAPIError +def test_scopes_deprecation_warning(): + """ + Test that using `scopes` parameter raises a deprecation warning. + """ + + with pytest.warns(DeprecationWarning) as record: + Security(dependency=lambda: None, scopes=["admin"]) + + assert len(record) == 1 + warning = record[0] + assert issubclass(warning.category, DeprecationWarning) + assert str(warning.message) == ( + "The 'scopes' parameter in Security() is deprecated in favor of " + "'oauth_scopes' in order to avoid confusion with 'scope' parameter." + ) + + @pytest.mark.parametrize("parameter_name", ["scopes", "oauth_scopes"]) @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_pass_single_str(parameter_name: str): From ffe1924289b04d240f315cd8af8c145d42560514 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 20:49:48 +0100 Subject: [PATCH 07/13] Rename module with tests to better reflect meaning --- ...ity_scopes_parameter.py => test_scope_and_scopes_confusion.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_security_scopes_parameter.py => test_scope_and_scopes_confusion.py} (100%) diff --git a/tests/test_security_scopes_parameter.py b/tests/test_scope_and_scopes_confusion.py similarity index 100% rename from tests/test_security_scopes_parameter.py rename to tests/test_scope_and_scopes_confusion.py From f42161cd19fadb7f4c35bdfbd89fe4ee52dc2add Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 21:41:12 +0100 Subject: [PATCH 08/13] Update note in docs --- docs/en/docs/advanced/security/oauth2-scopes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/docs/advanced/security/oauth2-scopes.md b/docs/en/docs/advanced/security/oauth2-scopes.md index 1d5acd7f0..eb4b2f8e9 100644 --- a/docs/en/docs/advanced/security/oauth2-scopes.md +++ b/docs/en/docs/advanced/security/oauth2-scopes.md @@ -110,9 +110,9 @@ You can use `Security` to declare dependencies (just like `Depends`), but `Secur /// note -Before version 0.121.4, the name of this parameter was `scopes`. +Before version 0.122.X, the name of this parameter was `scopes`. -Since FastAPI 0.121.4, the `scopes` parameter has been deprecated in favor of `oauth_scopes` +In FastAPI 0.122.X, the `scopes` parameter was deprecated in favor of `oauth_scopes` to avoid confusing it with the `scope` parameter, which is used to specify when the exit code of dependencies with `yield` should run. From 367b272a6096bc546eefbd0408ba27b399343f70 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Tue, 25 Nov 2025 22:01:16 +0100 Subject: [PATCH 09/13] Fix `scopes` deprecation message and `Security` docstring --- fastapi/param_functions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index eae6f0ae3..7c48ec15d 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -2396,6 +2396,8 @@ def Security( # noqa: N802 ```python Security(dependency_fn, oauth_scopes=["items", "users"]) ``` + + ​ """ ), ] = None, @@ -2438,8 +2440,8 @@ def Security( # noqa: N802 Declare a FastAPI Security dependency. The only difference with a regular dependency is that it can declare OAuth2 - scopes that will be integrated with OpenAPI and the automatic UI docs (by default - at `/docs`). + scopes (`oauth_scopes` parameter) that will be integrated with OpenAPI and the + automatic UI docs (by default at `/docs`). It takes a single "dependable" callable (like a function). From be6db923bf38fda80e6993b549f66dca99c0d716 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 1 Dec 2025 09:33:14 +0100 Subject: [PATCH 10/13] Update code and tests to use `oauth_scopes` instead of `scopes` --- fastapi/dependencies/utils.py | 2 +- tests/test_security_scopes.py | 2 +- tests/test_security_scopes_dont_propagate.py | 6 +++--- tests/test_security_scopes_sub_dependency.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index e765bbc74..a4dc5ca19 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -127,7 +127,7 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De ) own_oauth_scopes: List[str] = [] if isinstance(depends, params.Security) and depends.oauth_scopes: - own_oauth_scopes.extend(depends.scopes) + own_oauth_scopes.extend(depends.oauth_scopes) return get_dependant( path=path, call=depends.dependency, diff --git a/tests/test_security_scopes.py b/tests/test_security_scopes.py index 248fd2bcc..4bcf5248f 100644 --- a/tests/test_security_scopes.py +++ b/tests/test_security_scopes.py @@ -25,7 +25,7 @@ def app_fixture(call_counter: Dict[str, int]): @app.get("/") def endpoint( db: Annotated[str, Depends(get_db)], - user: Annotated[str, Security(get_user, scopes=["read"])], + user: Annotated[str, Security(get_user, oauth_scopes=["read"])], ): return {"db": db} diff --git a/tests/test_security_scopes_dont_propagate.py b/tests/test_security_scopes_dont_propagate.py index 2bbcc749d..1da6544b8 100644 --- a/tests/test_security_scopes_dont_propagate.py +++ b/tests/test_security_scopes_dont_propagate.py @@ -17,8 +17,8 @@ async def security2(scopes: SecurityScopes): async def dep3( - dep1: Annotated[List[str], Security(security1, scopes=["scope1"])], - dep2: Annotated[List[str], Security(security2, scopes=["scope2"])], + dep1: Annotated[List[str], Security(security1, oauth_scopes=["scope1"])], + dep2: Annotated[List[str], Security(security2, oauth_scopes=["scope2"])], ): return {"dep1": dep1, "dep2": dep2} @@ -28,7 +28,7 @@ app = FastAPI() @app.get("/scopes") def get_scopes( - dep3: Annotated[Dict[str, Any], Security(dep3, scopes=["scope3"])], + dep3: Annotated[Dict[str, Any], Security(dep3, oauth_scopes=["scope3"])], ): return dep3 diff --git a/tests/test_security_scopes_sub_dependency.py b/tests/test_security_scopes_sub_dependency.py index 9cc668d8e..e1ad3248f 100644 --- a/tests/test_security_scopes_sub_dependency.py +++ b/tests/test_security_scopes_sub_dependency.py @@ -37,7 +37,7 @@ def app_fixture(call_counts: Dict[str, int]): } def get_user_me( - current_user: Annotated[dict, Security(get_current_user, scopes=["me"])], + current_user: Annotated[dict, Security(get_current_user, oauth_scopes=["me"])], ): call_counts["get_user_me"] += 1 return { @@ -59,7 +59,7 @@ def app_fixture(call_counts: Dict[str, int]): @app.get("/") def path_operation( user_me: Annotated[dict, Depends(get_user_me)], - user_items: Annotated[dict, Security(get_user_items, scopes=["items"])], + user_items: Annotated[dict, Security(get_user_items, oauth_scopes=["items"])], ): return { "user_me": user_me, From ca186edcf35191aea0979dbe3dc4fd2101739ecd Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 8 Jan 2026 15:10:58 +0100 Subject: [PATCH 11/13] Fix `scopes` -> `oauth_scopes` --- ...ity_oauth2_authorization_code_bearer_scopes_openapi.py | 8 ++++---- ...th2_authorization_code_bearer_scopes_openapi_simple.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py index 583007c8b..b99e700e3 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py @@ -29,20 +29,20 @@ async def root(): @app.get( "/with-oauth2-scheme", - dependencies=[Security(oauth2_scheme, scopes=["read", "write"])], + dependencies=[Security(oauth2_scheme, oauth_scopes=["read", "write"])], ) async def read_with_oauth2_scheme(): return {"message": "Admin Access"} @app.get( - "/with-get-token", dependencies=[Security(get_token, scopes=["read", "write"])] + "/with-get-token", dependencies=[Security(get_token, oauth_scopes=["read", "write"])] ) async def read_with_get_token(): return {"message": "Admin Access"} -router = APIRouter(dependencies=[Security(oauth2_scheme, scopes=["read"])]) +router = APIRouter(dependencies=[Security(oauth2_scheme, oauth_scopes=["read"])]) @router.get("/items/") @@ -52,7 +52,7 @@ async def read_items(token: Optional[str] = Depends(oauth2_scheme)): @router.post("/items/") async def create_item( - token: Optional[str] = Security(oauth2_scheme, scopes=["read", "write"]), + token: Optional[str] = Security(oauth2_scheme, oauth_scopes=["read", "write"]), ): return {"token": token} diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py index 1c21369d3..cf04eff58 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py @@ -21,7 +21,7 @@ async def get_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str: app = FastAPI(dependencies=[Depends(get_token)]) -@app.get("/admin", dependencies=[Security(get_token, scopes=["read", "write"])]) +@app.get("/admin", dependencies=[Security(get_token, oauth_scopes=["read", "write"])]) async def read_admin(): return {"message": "Admin Access"} From 55b663f70f10b53e1cde495c0933c01e92917024 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:12:34 +0000 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...security_oauth2_authorization_code_bearer_scopes_openapi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py index b99e700e3..97d79462a 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py @@ -36,7 +36,8 @@ async def read_with_oauth2_scheme(): @app.get( - "/with-get-token", dependencies=[Security(get_token, oauth_scopes=["read", "write"])] + "/with-get-token", + dependencies=[Security(get_token, oauth_scopes=["read", "write"])], ) async def read_with_get_token(): return {"message": "Admin Access"} From 0ccc4a233bba52e00e0cf7b45890daf87c254887 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:31:42 +0000 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs_src/security/tutorial005_an_py39.py | 12 ++++++------ docs_src/security/tutorial005_py39.py | 11 +++++------ tests/test_dependency_paramless.py | 4 ++-- ...auth2_authorization_code_bearer_scopes_openapi.py | 6 +++--- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs_src/security/tutorial005_an_py39.py b/docs_src/security/tutorial005_an_py39.py index c8d8a757e..8c12c215f 100644 --- a/docs_src/security/tutorial005_an_py39.py +++ b/docs_src/security/tutorial005_an_py39.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Annotated, Union +from typing import Annotated import jwt from fastapi import Depends, FastAPI, HTTPException, Security, status @@ -43,15 +43,15 @@ class Token(BaseModel): class TokenData(BaseModel): - username: Union[str, None] = None + username: str | None = None scopes: list[str] = [] class User(BaseModel): username: str - email: Union[str, None] = None - full_name: Union[str, None] = None - disabled: Union[bool, None] = None + email: str | None = None + full_name: str | None = None + disabled: bool | None = None class UserInDB(User): @@ -91,7 +91,7 @@ def authenticate_user(fake_db, username: str, password: str): return user -def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None): +def create_access_token(data: dict, expires_delta: timedelta | None = None): to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta diff --git a/docs_src/security/tutorial005_py39.py b/docs_src/security/tutorial005_py39.py index 839e77fb3..d25ab869d 100644 --- a/docs_src/security/tutorial005_py39.py +++ b/docs_src/security/tutorial005_py39.py @@ -1,5 +1,4 @@ from datetime import datetime, timedelta, timezone -from typing import Union import jwt from fastapi import Depends, FastAPI, HTTPException, Security, status @@ -43,15 +42,15 @@ class Token(BaseModel): class TokenData(BaseModel): - username: Union[str, None] = None + username: str | None = None scopes: list[str] = [] class User(BaseModel): username: str - email: Union[str, None] = None - full_name: Union[str, None] = None - disabled: Union[bool, None] = None + email: str | None = None + full_name: str | None = None + disabled: bool | None = None class UserInDB(User): @@ -91,7 +90,7 @@ def authenticate_user(fake_db, username: str, password: str): return user -def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None): +def create_access_token(data: dict, expires_delta: timedelta | None = None): to_encode = data.copy() if expires_delta: expire = datetime.now(timezone.utc) + expires_delta diff --git a/tests/test_dependency_paramless.py b/tests/test_dependency_paramless.py index e53c6006e..26ca7dead 100644 --- a/tests/test_dependency_paramless.py +++ b/tests/test_dependency_paramless.py @@ -1,4 +1,4 @@ -from typing import Annotated, Union +from typing import Annotated from fastapi import FastAPI, HTTPException, Security from fastapi.security import ( @@ -13,7 +13,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def process_auth( - credentials: Annotated[Union[str, None], Security(oauth2_scheme)], + credentials: Annotated[str | None, Security(oauth2_scheme)], security_scopes: SecurityScopes, ): # This is an incorrect way of using it, this is not checking if the scopes are diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py index 97d79462a..7dd3421c1 100644 --- a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py @@ -1,6 +1,6 @@ # Ref: https://github.com/fastapi/fastapi/issues/14454 -from typing import Annotated, Optional +from typing import Annotated from fastapi import APIRouter, Depends, FastAPI, Security from fastapi.security import OAuth2AuthorizationCodeBearer @@ -47,13 +47,13 @@ router = APIRouter(dependencies=[Security(oauth2_scheme, oauth_scopes=["read"])] @router.get("/items/") -async def read_items(token: Optional[str] = Depends(oauth2_scheme)): +async def read_items(token: str | None = Depends(oauth2_scheme)): return {"token": token} @router.post("/items/") async def create_item( - token: Optional[str] = Security(oauth2_scheme, oauth_scopes=["read", "write"]), + token: str | None = Security(oauth2_scheme, oauth_scopes=["read", "write"]), ): return {"token": token}