
Chapter Outline
Chapter 6: OAuth2 Scopes, Token Expiry, and Advanced Authorization
In this chapter, we deepen our understanding of authorization by introducing:
- OAuth2 scopes for fine-grained access control
- Token expiration and validation workflows
- A conceptual overview of refresh tokens and token rotation
- An introduction to third-party identity providers (IDPs) like Google/Auth0
We'll continue using the mock user database and JWT authentication from earlier chapters. This is a theory-heavy chapter with minimal new endpoints, focusing instead on expanding your ability to design secure and scalable auth flows.
6.1 OAuth 2.0 Scopes
OAuth2 scopes offer a powerful mechanism for fine-grained authorization. While roles (like admin or user) define general categories of access, scopes specify the exact permissions granted to a client or user. In the context of our Todo app, scopes allow us to define explicit actions—such as read:todos for viewing tasks and write:todos for creating or modifying them—thereby enabling greater control over what each token permits.
This model is particularly useful when an application integrates with third-party clients or frontend apps that don’t need full access to the user's data. For example, a mobile app might only request read:todos, while an internal admin dashboard could request both read:todos and write:todos. By validating these scopes on each protected route, we ensure that tokens are not only valid, but also used within the boundaries they were granted. In the upcoming chapter, we’ll demonstrate how to issue scoped tokens and enforce these constraints using FastAPI’s built-in SecurityScopes utility.
For additional information about OAuth2 scopes, refer to the documentation here.
6.2 Scopes with OAuth2PasswordBearer
FastAPI lets us define expected scopes using the OAuth2PasswordBearer class and SecurityScopes.
6.2.1 Defining Scopes
In the following code we are declaring the OAuth2 security scheme with three available scopes, read:todos, write:todos, and admin. The scopes parameter receives a dict with each key as the scope and the value representing the description of the scope.
app/auth/service.py1from fastapi.security import OAuth2PasswordBearer23oauth2_scheme = OAuth2PasswordBearer(4 tokenUrl="/token",5 scopes={6 "read:todos": "Read access to user's todos",7 "write:todos": "Write access to user's todos",8 "admin": "Admin-only operations"9 }10)
6.2.2 Issuing scoped tokens
Include the scope assigned to the user in the database as one of the JWT access token's claim.
app/auth/router.py1@router.post("/token", response_model=Token, response_model_exclude_none=True)2def login(3 form_data: OAuth2PasswordRequestForm = Depends(),4 user_service: UserService = Depends(get_user_service),5 auth_service: AuthService = Depends(get_auth_service)6):7 """Authenticate user and return JWT token."""8 fetched_user = user_service.get_user(form_data.username)910 if not fetched_user or not auth_service.verify_password(11 form_data.password, fetched_user.hashed_password):12 raise HTTPException(status_code=401, detail="Incorrect username or password")13 user = User(14 id=fetched_user.id,15 username=fetched_user.username,16 disabled=fetched_user.disabled,17 role=fetched_user.role,18 name=fetched_user.name,19 email=fetched_user.email,20 scopes=fetched_user.scopes,21 )22 access_token = auth_service.create_token(23 {24 "sub": user.username,25 "scopes": user.scopes,26 "role": user.role27 },28 expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)29 )30 refresh_token = auth_service.create_token(31 {"sub": user.username, "scopes": user.scopes, "type": "refresh"},32 expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))33 return {34 "access_token": access_token,35 "token_type": "bearer",36 "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,37 "refresh_token": refresh_token,38 "info": {39 "name": user.name,40 "email": user.email,41 }42 }
6.2.3 Validating Scopes from Token
Let's update the existing get_current_user() dependecy function to allow verifying that the user's access token has valid scopes assigned:
app/auth/dependencies.py1from fastapi import Security, HTTPException, status2from fastapi.security import SecurityScopes3from jose import JWTError, jwt45async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):6 """Extract 'sub' (username) from JWT token."""7 payload = AuthService.decode_token(token)8 token_scopes = payload.get("scopes", [])910 for scope in security_scopes.scopes:11 if scope not in token_scopes:12 raise HTTPException(13 status_code=403,14 detail=f"Missing required scope: {scope}"15 )1617 return payload # or user object
6.2.4 Protecting Routes with Scope Checks
In the following route handlers for todo items, we are ensuring that the user has necessary permission to access the list_todos, and create_todo functions. The permissions are represented within the scopes array of the security dependencies.
app/todos/router.py1@router.get("", dependencies=[Security(get_current_user, scopes=["read:todos"])], response_model=List[TodoItem])2def list_todos(3 todo_service: TodoService = Depends(get_todo_service),4) -> List[TodoItem]:5 """List todos, but only if API key is valid."""6 return todo_service.list_todos()78@router.post("", dependencies=[Security(get_current_user, scopes=["write:todos"])], response_model=TodoItem)9def create_todo(10 todo: TodoCreate,11 todo_service: TodoService = Depends(get_todo_service),12) -> TodoItem:13 """Create a new Todo item."""14 return todo_service.create_todo(title=todo.title, completed=False)
This is identical to how Google, Microsoft, Auth0, and Amazon issue and validate scopes.
6.3 Token Expiration and Handling
In most modern stacks (including FastAPI), the access token is a JWT with an exp claim:
json{"sub": "user_123","scopes": ["read:todos", "write:todos"],"exp": 1732403100}
- The
exptimestamp says “this token is valid until this time.” - On each request, your FastAPI dependency decodes the token; the JWT library checks
exp. - If the token is expired, you should treat the client as unauthenticated and return 401 Unauthorized.
6.3.1 Generating token that expire and validating expiration
We are already encoding an exp within our JWT when we create it, we should simply aware of it when we decord the token.
app/auth/service.py1@staticmethod2def decode_token(token: str) -> Dict:3 """Decode JWT token; raise HTTPException if invalid or expired."""4 try:5 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])6 return payload78 except ExpiredSignatureError:9 # Token is structurally valid but expired10 raise HTTPException(11 status_code=status.HTTP_401_UNAUTHORIZED,12 detail="Token expired",13 )14 except JWTError:15 # Invalid signature, malformed token, etc.16 raise HTTPException(17 status_code=status.HTTP_401_UNAUTHORIZED,18 detail="Invalid token",19 )2021@staticmethod22def create_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:23 """Create JWT token with optional expiration delta."""24 to_encode = data.copy()25 expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))26 to_encode.update({"exp": expire})27 encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)28 return encoded_jwt
6.3.2 Testing Expiration
bashcurl -i -X GET http://127.0.0.1:8000/api/todos \-H "Authorization: Bearer <expired-token>"
You should see a response similar to the following:
bashHTTP/1.1 401 Unauthorizeddate: ***server: uvicorncontent-length: 25content-type: application/json{"error":"Token expired"}
6.4 Refresh Tokens
If your access tokens are short-lived (e.g. 5–30 minutes), you don’t want users to log in again every time the access token expires. That’s where a refresh token comes in.
6.4.1 How refresh tokens fit in
- Access token
- Short-lived (minutes)
- Sent on every API call via
Authorization: Bearer ... - If stolen, attacker has limited-time access
- Refresh token
- Long-lived (days/weeks)
- Used only to get a new access token
- Often stored more securely (e.g. HttpOnly cookie)
- If stolen, attacker can continuously mint new access tokens → must be protected carefully
Relationship:
- Access token = “ticket” to call APIs.
- Refresh token = “membership card” you bring back to the auth server to get a new ticket.
You typically issue them together at login:
json{"access_token": "<short-lived-jwt>","refresh_token": "<opaque-or-longer-lived-jwt>","token_type": "bearer","expires_in": 1800}
6.4.2 How to issue refresh tokens
A refresh token is generated and appended to response for login.
app/auth/router.py1@router.post("/token", response_model=Token, response_model_exclude_none=True)2def login(3 form_data: OAuth2PasswordRequestForm = Depends(),4 user_service: UserService = Depends(get_user_service),5 auth_service: AuthService = Depends(get_auth_service)6):7 """Authenticate user and return JWT token."""8 ...9 access_token = auth_service.create_token(10 {"sub": user.username, "scopes": user.scopes, "role": user.role},11 expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)12 )13 refresh_token = auth_service.create_token(14 {"sub": user.username, "scopes": user.scopes, "type": "refresh"},15 expires_delta=timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS))16 return {17 "access_token": access_token,18 "token_type": "bearer",19 "expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,20 "refresh_token": refresh_token,21 "info": {22 "name": user.name,23 "email": user.email,24 }25 }
6.4.3 How clients know when to use refresh tokens
Two main strategies:
A. Reactive: Refresh after a 401
- React app sends request with access token.
- API returns 401 with
"Token expired"detail. - Client catches this, calls
/auth/refreshwith refresh token. - If refresh succeeds, client retries the original request with the new access token.
Pros:
- Simple to implement.
- No need to track exact expiry time in the client.
Cons:
- User sees a small delay on the first expired request.
- If the refresh fails (refresh token revoked/expired), you must log the user out.
B. Proactive: Refresh before expiration
Client keeps track of exp and schedules a refresh a little before expiry (e.g. at exp - 60s):
- During login, client decodes the access token and extracts
exp. - Client sets a timer to call
/auth/refreshsome seconds before expiry. - Access token is silently renewed in the background; user rarely sees a 401.
Pros:
- Smoother UX, fewer 401s.
- Better suited to SPAs and mobile apps.
Cons:
- Slightly more complex (timers, token decoding).
- Must handle edge cases where refresh token is revoked/expired anyway.
In practice, SPAs often do a hybrid: proactive refresh most of the time, reactive refresh as fallback.
6.5 External Identity Providers (Conceptual)
OAuth2 lets users authenticate using services like Google, GitHub, or Auth0.
6.5.1 How It Works (Simplified)
- Your app redirects the user to an IDP login page
- The user logs in and authorizes your app
- The IDP sends your app an authorization code (via redirect)
- Your app exchanges that code for an access token
- You use the access token to fetch user info and create a local session
?client_id&redirect_uri&scope&state AS->>User: Login & Consent Screen User->>AS: Enters Credentials & Approves AS->>Client: Redirect back to redirect_uri
with ?code & state Client->>Backend: Send authorization code Backend->>AS: POST /token
client_id + secret + code + redirect_uri AS->>Backend: Returns Token(s) Backend->>Client: Session established / Token stored Client->>Backend: API request with Access Token Backend->>Client: Protected resource
Fig 6.1: Authorization Code Flow
6.6 Chapter Summary
In this chapter we took looked further into authorization by using OAuth 2. scopes. We also reviewed access token expiration, and using refresh token to generate access tokens in the event an access token has expired without having the user to login again.
Finally we outlined the Autorization Code Grant flow, which uses third party IDPs to issue access token for protecting backend resources.
The topic of authorization and OAuth is vast. Even though we reviewed a limited subset of the topics you should be able to review the documentation of other OAuth 2 grant types, and different authorization mechanisms, and be able to confidently implement them when necessary.
6.7 Further Reading
- FastAPI Security & Scopes: https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/
- Google Identity Docs: https://developers.google.com/identity
- Auth0 FastAPI integration: https://auth0.com/docs/quickstart/backend/python
Check your understanding
Test your knowledge of Advanced Authorization in FastAPI