Mathew K Analytics

Lesson 12 · FastAPI deep dive

FastAPI Tutorial #12: Authentication Basics with OAuth2

Video twelve of the eighteen-part series: proving who's calling, before JWT enters the picture. OAuth2PasswordBearer, form-based login, password hashing…

⬇ Download notebookOpen in Colab ↗

📓 Full notebook

Download .ipynb

FastAPI Deep-Dive, Video 12: Authentication Basics#

  • Video twelve of the eighteen-part series: proving who's calling, before JWT enters the picture.
  • OAuth2PasswordBearer, form-based login, password hashing with passlib, and protecting endpoints.
  • Let's get into it.

Part 1: The Problem - Storing Passwords in Plain Text#

from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
BAD_USERS = {'alex': 'hunter22'}
print(BAD_USERS)
{'alex': 'hunter22'}

Part 2: passlib - Hashing Passwords Properly#

from passlib.context import CryptContext
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
hashed = pwd_context.hash('hunter22')
print(hashed[:20])
print(hashed == 'hunter22')
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[2], line 1
----> 1 from passlib.context import CryptContext
      2 pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
      3 hashed = pwd_context.hash('hunter22')

ModuleNotFoundError: No module named 'passlib'

Part 3: Verifying a Password Against Its Hash#

print(pwd_context.verify('hunter22', hashed))
print(pwd_context.verify('wrong-password', hashed))
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[3], line 1
----> 1 print(pwd_context.verify('hunter22', hashed))
      2 print(pwd_context.verify('wrong-password', hashed))

NameError: name 'pwd_context' is not defined

Part 4: A Fake User Database With Hashed Passwords#

USERS_DB = {
    'alex': {'username': 'alex', 'hashed_password': pwd_context.hash('hunter22')},
}
def verify_login(username: str, password: str):
    user = USERS_DB.get(username)
    if not user:
        return False
    return pwd_context.verify(password, user['hashed_password'])
print(verify_login('alex', 'hunter22'))
print(verify_login('alex', 'wrong'))
print(verify_login('nobody', 'hunter22'))
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[4], line 2
      1 USERS_DB = {
----> 2     'alex': {'username': 'alex', 'hashed_password': pwd_context.hash('hunter22')},
      3 }
      4 def verify_login(username: str, password: str):
      5     user = USERS_DB.get(username)

NameError: name 'pwd_context' is not defined

Part 5: OAuth2PasswordBearer - Extracting the Token#

from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token')
@app.get('/whoami')
def whoami(token: str = Depends(oauth2_scheme)):
    return {'token': token}
client = TestClient(app)
print(client.get('/whoami', headers={'Authorization': 'Bearer abc123'}).json())
{'token': 'abc123'}

Part 6: Missing Token - Automatically Rejected#

response = client.get('/whoami')
print(response.status_code)
print(response.headers.get('www-authenticate'))
401
Bearer

Part 7: OAuth2PasswordRequestForm - the Login Endpoint#

from fastapi.security import OAuth2PasswordRequestForm
@app.post('/token')
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    if not verify_login(form_data.username, form_data.password):
        raise HTTPException(status_code=401, detail='incorrect username or password')
    return {'access_token': f'token-for-{form_data.username}', 'token_type': 'bearer'}
print(client.post('/token', data={'username': 'alex', 'password': 'hunter22'}).json())
print(client.post('/token', data={'username': 'alex', 'password': 'wrong'}).status_code)
Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[7], line 2
      1 from fastapi.security import OAuth2PasswordRequestForm
----> 2 @app.post('/token')
      3 def login(form_data: OAuth2PasswordRequestForm = Depends()):
      4     if not verify_login(form_data.username, form_data.password):
      5         raise HTTPException(status_code=401, detail='incorrect username or password')

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1128, in APIRouter.api_route.<locals>.decorator(func)
   1127 def decorator(func: DecoratedCallable) -> DecoratedCallable:
-> 1128     self.add_api_route(
   1129         path,
   1130         func,
   1131         response_model=response_model,
   1132         status_code=status_code,
   1133         tags=tags,
   1134         dependencies=dependencies,
   1135         summary=summary,
   1136         description=description,
   1137         response_description=response_description,
   1138         responses=responses,
   1139         deprecated=deprecated,
   1140         methods=methods,
   1141         operation_id=operation_id,
   1142         response_model_include=response_model_include,
   1143         response_model_exclude=response_model_exclude,
   1144         response_model_by_alias=response_model_by_alias,
   1145         response_model_exclude_unset=response_model_exclude_unset,
   1146         response_model_exclude_defaults=response_model_exclude_defaults,
   1147         response_model_exclude_none=response_model_exclude_none,
   1148         include_in_schema=include_in_schema,
   1149         response_class=response_class,
   1150         name=name,
   1151         callbacks=callbacks,
   1152         openapi_extra=openapi_extra,
   1153         generate_unique_id_function=generate_unique_id_function,
   1154     )
   1155     return func

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1067, in APIRouter.add_api_route(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, name, route_class_override, callbacks, openapi_extra, generate_unique_id_function)
   1063     current_callbacks.extend(callbacks)
   1064 current_generate_unique_id = get_value_or_default(
   1065     generate_unique_id_function, self.generate_unique_id_function
   1066 )
-> 1067 route = route_class(
   1068     self.prefix + path,
   1069     endpoint=endpoint,
   1070     response_model=response_model,
   1071     status_code=status_code,
   1072     tags=current_tags,
   1073     dependencies=current_dependencies,
   1074     summary=summary,
   1075     description=description,
   1076     response_description=response_description,
   1077     responses=combined_responses,
   1078     deprecated=deprecated or self.deprecated,
   1079     methods=methods,
   1080     operation_id=operation_id,
   1081     response_model_include=response_model_include,
   1082     response_model_exclude=response_model_exclude,
   1083     response_model_by_alias=response_model_by_alias,
   1084     response_model_exclude_unset=response_model_exclude_unset,
   1085     response_model_exclude_defaults=response_model_exclude_defaults,
   1086     response_model_exclude_none=response_model_exclude_none,
   1087     include_in_schema=include_in_schema and self.include_in_schema,
   1088     response_class=current_response_class,
   1089     name=name,
   1090     dependency_overrides_provider=self.dependency_overrides_provider,
   1091     callbacks=current_callbacks,
   1092     openapi_extra=openapi_extra,
   1093     generate_unique_id_function=current_generate_unique_id,
   1094 )
   1095 self.routes.append(route)

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:686, in APIRoute.__init__(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, name, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, dependency_overrides_provider, callbacks, openapi_extra, generate_unique_id_function)
    683     self.response_fields = {}
    685 assert callable(endpoint), "An endpoint must be a callable"
--> 686 self.dependant = get_dependant(
    687     path=self.path_format, call=self.endpoint, scope="function"
    688 )
    689 for depends in self.dependencies[::-1]:
    690     self.dependant.dependencies.insert(
    691         0,
    692         get_parameterless_sub_dependant(depends=depends, path=self.path_format),
    693     )

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:310, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    308     if param_details.depends.scopes:
    309         sub_own_oauth_scopes = list(param_details.depends.scopes)
--> 310 sub_dependant = get_dependant(
    311     path=path,
    312     call=param_details.depends.dependency,
    313     name=param_name,
    314     own_oauth_scopes=sub_own_oauth_scopes,
    315     parent_oauth_scopes=current_scopes,
    316     use_cache=param_details.depends.use_cache,
    317     scope=param_details.depends.scope,
    318 )
    319 dependant.dependencies.append(sub_dependant)
    320 continue

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:288, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    286 for param_name, param in signature_params.items():
    287     is_path_param = param_name in path_param_names
--> 288     param_details = analyze_param(
    289         param_name=param_name,
    290         annotation=param.annotation,
    291         value=param.default,
    292         is_path_param=is_path_param,
    293     )
    294     if param_details.depends is not None:
    295         assert param_details.depends.dependency

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:525, in analyze_param(param_name, annotation, value, is_path_param)
    519 use_annotation_from_field_info = get_annotation_from_field_info(
    520     use_annotation,
    521     field_info,
    522     param_name,
    523 )
    524 if isinstance(field_info, (params.Form, temp_pydantic_v1_params.Form)):
--> 525     ensure_multipart_is_installed()
    526 if not field_info.alias and getattr(field_info, "convert_underscores", None):
    527     alias = param_name.replace("_", "-")

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:121, in ensure_multipart_is_installed()
    119 except ImportError:
    120     logger.error(multipart_not_installed_error)
--> 121     raise RuntimeError(multipart_not_installed_error) from None

RuntimeError: Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

Part 8: get_current_user - Resolving a Token Back to a User#

ISSUED_TOKENS = {}
def get_current_user(token: str = Depends(oauth2_scheme)):
    username = ISSUED_TOKENS.get(token)
    if not username:
        raise HTTPException(status_code=401, detail='invalid or expired token')
    return USERS_DB[username]
print(len(ISSUED_TOKENS))
0

Part 9: A Protected Endpoint Using get_current_user#

@app.post('/token-v2')
def login_v2(form_data: OAuth2PasswordRequestForm = Depends()):
    if not verify_login(form_data.username, form_data.password):
        raise HTTPException(status_code=401, detail='incorrect username or password')
    token = f'token-for-{form_data.username}'
    ISSUED_TOKENS[token] = form_data.username
    return {'access_token': token, 'token_type': 'bearer'}
@app.get('/users/me')
def read_me(current_user: dict = Depends(get_current_user)):
    return {'username': current_user['username']}
login_response = client.post('/token-v2', data={'username': 'alex', 'password': 'hunter22'})
issued_token = login_response.json()['access_token']
print(client.get('/users/me', headers={'Authorization': f'Bearer {issued_token}'}).json())
print(client.get('/users/me', headers={'Authorization': 'Bearer made-up-token'}).status_code)
Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[9], line 1
----> 1 @app.post('/token-v2')
      2 def login_v2(form_data: OAuth2PasswordRequestForm = Depends()):
      3     if not verify_login(form_data.username, form_data.password):
      4         raise HTTPException(status_code=401, detail='incorrect username or password')

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1128, in APIRouter.api_route.<locals>.decorator(func)
   1127 def decorator(func: DecoratedCallable) -> DecoratedCallable:
-> 1128     self.add_api_route(
   1129         path,
   1130         func,
   1131         response_model=response_model,
   1132         status_code=status_code,
   1133         tags=tags,
   1134         dependencies=dependencies,
   1135         summary=summary,
   1136         description=description,
   1137         response_description=response_description,
   1138         responses=responses,
   1139         deprecated=deprecated,
   1140         methods=methods,
   1141         operation_id=operation_id,
   1142         response_model_include=response_model_include,
   1143         response_model_exclude=response_model_exclude,
   1144         response_model_by_alias=response_model_by_alias,
   1145         response_model_exclude_unset=response_model_exclude_unset,
   1146         response_model_exclude_defaults=response_model_exclude_defaults,
   1147         response_model_exclude_none=response_model_exclude_none,
   1148         include_in_schema=include_in_schema,
   1149         response_class=response_class,
   1150         name=name,
   1151         callbacks=callbacks,
   1152         openapi_extra=openapi_extra,
   1153         generate_unique_id_function=generate_unique_id_function,
   1154     )
   1155     return func

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:1067, in APIRouter.add_api_route(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, name, route_class_override, callbacks, openapi_extra, generate_unique_id_function)
   1063     current_callbacks.extend(callbacks)
   1064 current_generate_unique_id = get_value_or_default(
   1065     generate_unique_id_function, self.generate_unique_id_function
   1066 )
-> 1067 route = route_class(
   1068     self.prefix + path,
   1069     endpoint=endpoint,
   1070     response_model=response_model,
   1071     status_code=status_code,
   1072     tags=current_tags,
   1073     dependencies=current_dependencies,
   1074     summary=summary,
   1075     description=description,
   1076     response_description=response_description,
   1077     responses=combined_responses,
   1078     deprecated=deprecated or self.deprecated,
   1079     methods=methods,
   1080     operation_id=operation_id,
   1081     response_model_include=response_model_include,
   1082     response_model_exclude=response_model_exclude,
   1083     response_model_by_alias=response_model_by_alias,
   1084     response_model_exclude_unset=response_model_exclude_unset,
   1085     response_model_exclude_defaults=response_model_exclude_defaults,
   1086     response_model_exclude_none=response_model_exclude_none,
   1087     include_in_schema=include_in_schema and self.include_in_schema,
   1088     response_class=current_response_class,
   1089     name=name,
   1090     dependency_overrides_provider=self.dependency_overrides_provider,
   1091     callbacks=current_callbacks,
   1092     openapi_extra=openapi_extra,
   1093     generate_unique_id_function=current_generate_unique_id,
   1094 )
   1095 self.routes.append(route)

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\routing.py:686, in APIRoute.__init__(self, path, endpoint, response_model, status_code, tags, dependencies, summary, description, response_description, responses, deprecated, name, methods, operation_id, response_model_include, response_model_exclude, response_model_by_alias, response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none, include_in_schema, response_class, dependency_overrides_provider, callbacks, openapi_extra, generate_unique_id_function)
    683     self.response_fields = {}
    685 assert callable(endpoint), "An endpoint must be a callable"
--> 686 self.dependant = get_dependant(
    687     path=self.path_format, call=self.endpoint, scope="function"
    688 )
    689 for depends in self.dependencies[::-1]:
    690     self.dependant.dependencies.insert(
    691         0,
    692         get_parameterless_sub_dependant(depends=depends, path=self.path_format),
    693     )

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:310, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    308     if param_details.depends.scopes:
    309         sub_own_oauth_scopes = list(param_details.depends.scopes)
--> 310 sub_dependant = get_dependant(
    311     path=path,
    312     call=param_details.depends.dependency,
    313     name=param_name,
    314     own_oauth_scopes=sub_own_oauth_scopes,
    315     parent_oauth_scopes=current_scopes,
    316     use_cache=param_details.depends.use_cache,
    317     scope=param_details.depends.scope,
    318 )
    319 dependant.dependencies.append(sub_dependant)
    320 continue

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:288, in get_dependant(path, call, name, own_oauth_scopes, parent_oauth_scopes, use_cache, scope)
    286 for param_name, param in signature_params.items():
    287     is_path_param = param_name in path_param_names
--> 288     param_details = analyze_param(
    289         param_name=param_name,
    290         annotation=param.annotation,
    291         value=param.default,
    292         is_path_param=is_path_param,
    293     )
    294     if param_details.depends is not None:
    295         assert param_details.depends.dependency

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:525, in analyze_param(param_name, annotation, value, is_path_param)
    519 use_annotation_from_field_info = get_annotation_from_field_info(
    520     use_annotation,
    521     field_info,
    522     param_name,
    523 )
    524 if isinstance(field_info, (params.Form, temp_pydantic_v1_params.Form)):
--> 525     ensure_multipart_is_installed()
    526 if not field_info.alias and getattr(field_info, "convert_underscores", None):
    527     alias = param_name.replace("_", "-")

File c:\Users\makmw\AppData\Local\Programs\Python\Python312\Lib\site-packages\fastapi\dependencies\utils.py:121, in ensure_multipart_is_installed()
    119 except ImportError:
    120     logger.error(multipart_not_installed_error)
--> 121     raise RuntimeError(multipart_not_installed_error) from None

RuntimeError: Form data requires "python-multipart" to be installed. 
You can install "python-multipart" with: 

pip install python-multipart

Part 10: A Real Pattern - a Small, Working Login Flow#

auth_app = FastAPI()
auth_scheme = OAuth2PasswordBearer(tokenUrl='login')
auth_users = {'sam': {'username': 'sam', 'hashed_password': pwd_context.hash('correct-horse')}}
auth_tokens = {}
def auth_verify_login(username: str, password: str):
    user = auth_users.get(username)
    if not user:
        return False
    return pwd_context.verify(password, user['hashed_password'])
def auth_get_current_user(token: str = Depends(auth_scheme)):
    username = auth_tokens.get(token)
    if not username:
        raise HTTPException(status_code=401, detail='invalid or expired token')
    return auth_users[username]
@auth_app.post('/login')
def auth_login(form_data: OAuth2PasswordRequestForm = Depends()):
    if not auth_verify_login(form_data.username, form_data.password):
        raise HTTPException(status_code=401, detail='incorrect username or password')
    token = f'token-for-{form_data.username}'
    auth_tokens[token] = form_data.username
    return {'access_token': token, 'token_type': 'bearer'}
@auth_app.get('/profile')
def auth_profile(current_user: dict = Depends(auth_get_current_user)):
    return {'username': current_user['username']}
auth_client = TestClient(auth_app)
print(auth_client.get('/profile').status_code)
login = auth_client.post('/login', data={'username': 'sam', 'password': 'correct-horse'})
token = login.json()['access_token']
print(auth_client.get('/profile', headers={'Authorization': f'Bearer {token}'}).json())
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[10], line 3
      1 auth_app = FastAPI()
      2 auth_scheme = OAuth2PasswordBearer(tokenUrl='login')
----> 3 auth_users = {'sam': {'username': 'sam', 'hashed_password': pwd_context.hash('correct-horse')}}
      4 auth_tokens = {}
      5 def auth_verify_login(username: str, password: str):

NameError: name 'pwd_context' is not defined

Wrap-Up: What You Learned#

  • A user store should hold a hashed password, never the raw original.
  • CryptContext with bcrypt hashes a password one-way; the original can never be recovered from it.
  • pwd_context.verify re-hashes a candidate password and compares, without ever reversing the hash.
  • OAuth2PasswordBearer extracts the raw token from a Bearer Authorization header via Depends.
  • A missing Authorization header is automatically rejected with 401, no extra endpoint code needed.
  • OAuth2PasswordRequestForm reads username and password from a form-encoded login request.
  • A get_current_user dependency resolves a token back to the specific user it belongs to.
  • A protected endpoint just depends on get_current_user; the login endpoint must issue real tokens.
  • This opaque, in-memory token pattern is the minimum flow that JWT later makes portable and stateless.
  • That wraps up authentication basics. Next up: JWT Authentication.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.