diff --git a/docs/en/docs/img/tutorial/security/image13.png b/docs/en/docs/img/tutorial/security/image13.png
new file mode 100644
index 000000000..05b45d583
Binary files /dev/null and b/docs/en/docs/img/tutorial/security/image13.png differ
diff --git a/docs/en/docs/img/tutorial/security/image14.png b/docs/en/docs/img/tutorial/security/image14.png
new file mode 100644
index 000000000..6d15f9153
Binary files /dev/null and b/docs/en/docs/img/tutorial/security/image14.png differ
diff --git a/docs/en/docs/img/tutorial/security/image15.png b/docs/en/docs/img/tutorial/security/image15.png
new file mode 100644
index 000000000..46b000594
Binary files /dev/null and b/docs/en/docs/img/tutorial/security/image15.png differ
diff --git a/docs/en/docs/tutorial/security/oidc-jwt.md b/docs/en/docs/tutorial/security/oidc-jwt.md
new file mode 100644
index 000000000..839d828a8
--- /dev/null
+++ b/docs/en/docs/tutorial/security/oidc-jwt.md
@@ -0,0 +1,115 @@
+# OpenID Connect (OIDC) with JWT Access Tokens
+
+For this tutorial we will be using OpenID Connect (OIDC) as an *authentication* layer that builds on top of the OAuth2 *authorization* layer.
+
+We will be using the Swagger UI to serve the OpenID Connect authentication flow. The **FastAPI** (default) router will implement a OAuth2 resource server that validates the JWT access tokens and grant access to the router's endpoints.
+
+We will use a custom claim to grant permission to endpoints for users with specific roles which are represented as a claim in the access token (there is no standard defined what the claim name is, so it has a configurable or *custom* name). Typically, the authorization server exposes the user's group membership in a specific claim in the JWT Access Token, which defaults to 'groups' in the tutorial.
+
+Note that the Swagger UI mirrors the OIDC main flow of the frontend that would be used in a production environment. Therefore the OIDC security scheme, as represented in the openAPI definition, is separate from the OAuth2 scheme.
+
+# Configure Requirements
+
+First, you will need to select an OpenID provider if you do not have one already. There are ones that offer free trials or free tiers to experiment with [here](https://identitymanagementinstitute.org/identity-and-access-management-vendor-list/).
+
+
+## Setup OpenID provder
+
+First, we will need to configure an Applicaton (i.e. Relying Party in OpenID-speak) in the OpenID provider. This application allows the **FastAPI** client that logs in to the OpenID Connect provider:
+!!! check "Step 1 - Create Application"
+ * Create an Application of type SPA
+ * Select Authorization Code, Refresh Token, Require PKCE
+ * Configure sign-in redirect URIs: `http://localhost:8080/docs/oauth2-redirect`
+ * Configure sign-out redirect URIs: `http://localhost:8080/docs/`
+ * *Write down the client id*
+
+Then, we will select an authorization server to verify user identities and issue tokens for secure authentication and authorization of login requests:
+!!! check "Step 2 - Configure authorization server to return a custom claim"
+ * Select/create a custom authorization server for the abovementioned application
+ * Create a custom claim with the name "`groups`".
+ * Map the values to the groups of which the authenticated user is member of
+ * *Write down issuer URL*
+ * *Write down audience*
+
+Finally, we will need to create a user and a group named "`Foo`" to
+!!! check "Step 3 - Create a user and group"
+ * Create a group called "`Foo`"
+ * Create a user
+ * Assign the "`Foo`"` group to the user
+ * Assign the application of step 1 to the user
+ * *Write down user/password as you will need to authenticate with it later*
+
+## Configure your **FastAPI** Application
+
+We assume a running pip environment with **FastAPI** installed (see [here](../../index.md#installation)).
+
+This example contains a `AccessTokenValidator` that validates the JWT access tokens using the jwks url that is part of the oidc well known configuration. It requires a Python JavaScript Object Signing and Encryprion (JOSE) library, a HTTP client to fetch keysets and some cache utilities.
+
+
+!!! check "Step 4 - Install AccessTokenValidator Dependencies"
+ ```console
+ pip install jose cachetools types-cachetools httpx
+ ```
+
+You need to fill in the values in the .env file that you wrote down from the previous steps:
+!!! check "Step 5 - Configure **FastAPI** environment"
+ ```
+ client_id = "Client Id of Step 1 here"
+ issuer = "Issuer URL of Step 2 here"
+ audience = "Audience of Step 2 here"
+ ```
+
+This was the final step of the configuration.
+
+# Running the **FastAPI** Application
+
+Finally we come to the actual **FastAPI** code:
+
+=== "Python 3.9+"
+
+ ```Python hl_lines="112-124 127-129 134"
+ {!> ../../../docs_src/security/tutorial008_an_py39.py!}
+ ```
+
+!!! check "some small tweaks necessary?"
+ * line 118, set usePkceWithAuthorizationCodeGrant if you require PKCE authentication (configured when you set up your application)
+ * line 116, add additional scopes to "openid" if your authorization requires this
+
+
+If you save this file as `main.py`, you can run the app [as normal](../../index.md#run-it), for instance:
+```bash
+uvicorn main:app --port 8080 --reload
+```
+(*If you do not specify the correct port defined in Step 1, the authentication flow will fail*)
+
+
+# Test the **FastAPI** Application
+
+When the application is running, you can then point your browser to the [Interactive API Docs](../../index.md#interactive-api-docs):
+`http://localhost:8080/docs/`
+
+Authenticate first in the Swagger UI using the 'Authorize' button at the top and scroll to the topmost authentication flow named **'OpenIdConnect (OAuth2, authorization_code with PKCE)'**:
+
+
+
+Then press the 'Authorize' button.
+
+When successfully authenticated, you will see that your session is 'authorized':
+
+
+
+Press the 'Close' button to close this screen.
+
+Then execute the /hello endpoint with your user if part of the "`Foo`" group:
+
+
+
+If you see "Hi!" as a response, your user was successfully authenticated and had the "`Foo`" role in the claim as required by the /hello endpoint.
+
+To understand the code step by step, it will help if you step through the code using a [Debugger](../debugging.md#run-your-code-with-your-debugger).
+
+Good luck!
+
+# Appendix - References
+
+* OIDC Terminology: https://openid.net/specs/openid-connect-core-1_0.html#Terminology
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml
index ba1ac7924..0ffb81a65 100644
--- a/docs/en/mkdocs.yml
+++ b/docs/en/mkdocs.yml
@@ -115,6 +115,7 @@ nav:
- tutorial/security/get-current-user.md
- tutorial/security/simple-oauth2.md
- tutorial/security/oauth2-jwt.md
+ - tutorial/security/oidc-jwt.md
- tutorial/middleware.md
- tutorial/cors.md
- tutorial/sql-databases.md
diff --git a/docs_src/security/tutorial008_an_py39.py b/docs_src/security/tutorial008_an_py39.py
new file mode 100644
index 000000000..7f0e63601
--- /dev/null
+++ b/docs_src/security/tutorial008_an_py39.py
@@ -0,0 +1,136 @@
+from typing import Annotated, Any, Dict, Optional
+
+import httpx
+from cachetools import TTLCache
+from fastapi import Depends, FastAPI, HTTPException, Security
+from fastapi.security import (
+ HTTPAuthorizationCredentials,
+ HTTPBearer,
+ OpenIdConnect,
+ SecurityScopes,
+)
+from jose import JWTError, jwt
+from pydantic import Field
+from pydantic_settings import BaseSettings
+from starlette.requests import Request
+from starlette.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
+
+
+class AccessTokenCredentials(HTTPAuthorizationCredentials):
+ token: Dict[str, Any]
+
+
+class AccessTokenValidator(HTTPBearer):
+ """Generic HTTPBearer Validator that validates JWT tokens given the JWKS provided at jwks_url."""
+
+ def __init__(
+ self,
+ *,
+ jwks_url: str,
+ audience: str,
+ issuer: str,
+ expire_seconds: int = 3600,
+ roles_claim: str = "groups",
+ scheme_name: Optional[str] = None,
+ description: Optional[str] = None,
+ ):
+ super().__init__(scheme_name=scheme_name, description=description)
+ self.uri = jwks_url
+ self.audience = audience
+ self.issuer = issuer
+ self.roles_claim = roles_claim
+ self.keyset_cache: TTLCache[str, str] = TTLCache(16, expire_seconds)
+
+ async def get_jwt_keyset(self) -> str:
+ """Retrieves keyset when expired/not cached yet."""
+ result: Optional[str] = self.keyset_cache.get(self.uri)
+ if result is None:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(self.uri)
+ result = self.keyset_cache[self.uri] = response.text
+ return result
+
+ async def __call__(self, request: Request, security_scopes: SecurityScopes) -> AccessTokenCredentials: # type: ignore
+ """Validates the JWT Access Token. If security_scopes are given, they are validated against the roles_claim in the Access Token."""
+ # 1. Unpack bearer token
+ unverified_token = await super().__call__(request)
+ if not unverified_token:
+ raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid Access Token")
+ access_token = unverified_token.credentials
+ try:
+ # 2. Get keyset from authorization server so that we can validate the JWT Access Token
+ keyset = await self.get_jwt_keyset()
+ # 3. Perform validation
+ verified_token = jwt.decode(
+ token=access_token,
+ key=keyset,
+ audience=self.audience,
+ issuer=self.issuer,
+ )
+ except JWTError:
+ raise HTTPException(
+ status_code=HTTP_400_BAD_REQUEST,
+ detail="Unsupported authorization code",
+ )
+
+ # 4. if security scopes are present, validate them
+ if security_scopes and security_scopes.scopes:
+ # 4.1 the roles_claim must be present in the access token
+ scopes = verified_token.get(self.roles_claim)
+ if scopes is None:
+ raise HTTPException(
+ status_code=HTTP_400_BAD_REQUEST, detail="Unsupported Access Token"
+ )
+ # 4.2 all required roles in the roles_claim must be present
+ if not set(security_scopes.scopes).issubset(set(scopes)):
+ raise HTTPException(
+ status_code=HTTP_403_FORBIDDEN, detail="Not Authorized"
+ )
+
+ return AccessTokenCredentials(
+ scheme=self.scheme_name, credentials=access_token, token=verified_token
+ )
+
+
+class Settings(BaseSettings):
+ """Settings wil be read from an .env file"""
+
+ issuer: str = Field(default=...)
+ audience: str = Field(default=...)
+ client_id: str = Field(default=...)
+
+ class Config:
+ env_file = ".env"
+
+
+settings = Settings()
+
+# Standard OIDC URLs
+oidc_url = f"{settings.issuer}/.well-known/openid-configuration"
+jwks_url = f"{settings.issuer}/v1/keys"
+
+openid_connect = OpenIdConnect(openIdConnectUrl=oidc_url)
+
+swagger_ui_init_oauth = {
+ "clientId": settings.client_id,
+ "scopes": ["openid"], # fill in additional scopes when necessary
+ "appName": "Test Application",
+ "usePkceWithAuthorizationCodeGrant": True,
+}
+
+# The openid_connect security scheme is given as a dependency so that you can authenticate using the swagger UI
+app = FastAPI(
+ swagger_ui_init_oauth=swagger_ui_init_oauth, dependencies=[Depends(openid_connect)]
+)
+
+# the tokenvalidator is used for all endpoints that need to be authorized
+oauth2 = AccessTokenValidator(
+ jwks_url=jwks_url, audience=settings.audience, issuer=settings.issuer
+)
+
+
+@app.get("/hello")
+async def hello(
+ token: Annotated[AccessTokenCredentials, Security(oauth2, scopes=["Foo"])]
+) -> str:
+ return "Hi!"