Skip to content

Commit 5683049

Browse files
authored
✨ Add dual token location pattern documentation and example (#786)
- Introduced a new section in the documentation detailing the dual location pattern for access and refresh tokens. - Added example code for implementing access tokens in headers and refresh tokens in HTTP-only cookies. - Created a new example script demonstrating the complete flow of login, token retrieval, refresh, and logout using this pattern.
1 parent e76bc6a commit 5683049

File tree

2 files changed

+349
-0
lines changed

2 files changed

+349
-0
lines changed

docs/get-started/location.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,133 @@ config = AuthXConfig(
253253

254254
AuthX will check each location in order until it finds a token.
255255

256+
---
257+
258+
## Dual Location Pattern (Access in Header, Refresh in Cookie)
259+
260+
A common and secure pattern for web applications is to use different locations for different token types:
261+
262+
- **Access tokens** in headers: Short-lived, stored in client memory, sent explicitly
263+
- **Refresh tokens** in HTTP-only cookies: Long-lived, secure storage, sent automatically
264+
265+
!!! tip "Why This Pattern?"
266+
- **Access tokens in memory**: Prevents XSS from stealing long-lived tokens
267+
- **Refresh tokens in HTTP-only cookies**: Cannot be accessed by JavaScript, automatic CSRF protection
268+
- **Headers for API calls**: Standard REST pattern, works with any client
269+
270+
### Configuration
271+
272+
```python
273+
from authx import AuthX, AuthXConfig
274+
275+
config = AuthXConfig(
276+
JWT_SECRET_KEY="your-secret-key",
277+
JWT_TOKEN_LOCATION=["headers", "cookies"], # Enable both
278+
# Cookie settings for refresh token security
279+
JWT_COOKIE_SECURE=True, # HTTPS only (set False for local dev)
280+
JWT_COOKIE_HTTP_ONLY=True, # Prevent JS access
281+
JWT_COOKIE_CSRF_PROTECT=True, # CSRF protection for refresh
282+
)
283+
auth = AuthX(config=config)
284+
```
285+
286+
### Login Endpoint
287+
288+
```python
289+
@app.post("/login")
290+
def login(user: LoginRequest, response: Response):
291+
# Validate credentials...
292+
293+
# Create both tokens
294+
access_token = auth.create_access_token(uid=user.username)
295+
refresh_token = auth.create_refresh_token(uid=user.username)
296+
297+
# Set ONLY refresh token in cookie
298+
auth.set_refresh_cookies(refresh_token, response)
299+
300+
# Return access token in response body
301+
return {"access_token": access_token, "token_type": "bearer"}
302+
```
303+
304+
### Protected Endpoints (Access Token from Header)
305+
306+
```python
307+
@app.get("/protected")
308+
async def protected(request: Request):
309+
# Explicitly get token from headers only
310+
access_token = await auth.get_access_token_from_request(
311+
request,
312+
locations=["headers"], # Only look in headers
313+
)
314+
payload = auth.verify_token(access_token, verify_csrf=False)
315+
return {"user": payload.sub}
316+
```
317+
318+
### Refresh Endpoint (Refresh Token from Cookie)
319+
320+
```python
321+
@app.post("/refresh")
322+
async def refresh(request: Request):
323+
# Get refresh token from cookies only
324+
refresh_token = await auth.get_refresh_token_from_request(
325+
request,
326+
locations=["cookies"], # Only look in cookies
327+
)
328+
payload = auth.verify_token(refresh_token)
329+
330+
# Return new access token in body
331+
new_access_token = auth.create_access_token(uid=payload.sub)
332+
return {"access_token": new_access_token, "token_type": "bearer"}
333+
```
334+
335+
### Logout Endpoint
336+
337+
```python
338+
@app.post("/logout")
339+
def logout(response: Response):
340+
# Clear refresh token cookie
341+
auth.unset_refresh_cookies(response)
342+
return {"message": "Logged out"}
343+
```
344+
345+
### Client-Side Flow
346+
347+
=== "JavaScript"
348+
```javascript
349+
// Login - store access token in memory
350+
const response = await fetch("/login", {
351+
method: "POST",
352+
credentials: "include", // Required for cookies
353+
headers: { "Content-Type": "application/json" },
354+
body: JSON.stringify({ username, password })
355+
});
356+
const { access_token } = await response.json();
357+
358+
// Store in memory (NOT localStorage)
359+
let accessToken = access_token;
360+
361+
// API calls - send access token in header
362+
await fetch("/protected", {
363+
headers: { "Authorization": `Bearer ${accessToken}` }
364+
});
365+
366+
// Refresh - cookie sent automatically
367+
function getCsrfToken() {
368+
return document.cookie.match(/csrf_refresh_token=([^;]+)/)?.[1];
369+
}
370+
371+
const refreshResponse = await fetch("/refresh", {
372+
method: "POST",
373+
credentials: "include",
374+
headers: { "X-CSRF-TOKEN": getCsrfToken() }
375+
});
376+
accessToken = (await refreshResponse.json()).access_token;
377+
```
378+
379+
See the [dual_token_location.py example](https://github.com/yezz123/authx/blob/main/examples/examples/dual_token_location.py) for a complete implementation.
380+
381+
---
382+
256383
## Cookie Configuration Reference
257384

258385
| Setting | Default | Description |
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Dual Token Location Example - Access Token in Header, Refresh Token in Cookie.
2+
3+
This example demonstrates a common and secure pattern for web applications:
4+
- Access tokens (short-lived) are sent via Authorization header
5+
- Refresh tokens (long-lived) are stored in HTTP-only cookies
6+
7+
Benefits of this approach:
8+
- Access tokens in headers: Standard for APIs, works with any client, no CSRF concerns
9+
- Refresh tokens in cookies: Secure storage, HTTP-only prevents XSS theft, automatic sending
10+
11+
Flow:
12+
1. Login: Returns access_token in response body, sets refresh_token as HTTP-only cookie
13+
2. API calls: Client sends access_token in Authorization header
14+
3. Refresh: Client calls /refresh, refresh_token is sent automatically via cookie
15+
4. Logout: Server clears the refresh_token cookie
16+
"""
17+
18+
from fastapi import FastAPI, HTTPException, Request, Response
19+
from pydantic import BaseModel
20+
21+
from authx import AuthX, AuthXConfig
22+
23+
# Create a FastAPI app
24+
app = FastAPI(title="AuthX Dual Token Location Example")
25+
26+
# Configure AuthX for dual token locations
27+
auth_config = AuthXConfig(
28+
JWT_ALGORITHM="HS256",
29+
JWT_SECRET_KEY="your-secret-key", # In production, use a secure key from environment variables
30+
# Enable both locations - we'll specify which to use per-endpoint
31+
JWT_TOKEN_LOCATION=["headers", "cookies"],
32+
# Header settings for access tokens
33+
JWT_HEADER_NAME="Authorization",
34+
JWT_HEADER_TYPE="Bearer",
35+
# Cookie settings for refresh tokens
36+
JWT_REFRESH_COOKIE_NAME="refresh_token_cookie",
37+
JWT_REFRESH_COOKIE_PATH="/",
38+
JWT_COOKIE_SECURE=False, # Set to True in production (requires HTTPS)
39+
JWT_COOKIE_HTTP_ONLY=True, # Prevent JavaScript access to refresh token
40+
JWT_COOKIE_SAMESITE="lax", # Protect against CSRF
41+
JWT_COOKIE_CSRF_PROTECT=True, # Enable CSRF protection for cookie-based refresh
42+
JWT_REFRESH_CSRF_COOKIE_NAME="csrf_refresh_token",
43+
JWT_REFRESH_CSRF_HEADER_NAME="X-CSRF-TOKEN",
44+
)
45+
46+
# Initialize AuthX
47+
auth = AuthX(config=auth_config)
48+
49+
# Register error handlers
50+
auth.handle_errors(app)
51+
52+
53+
# Define models
54+
class LoginRequest(BaseModel):
55+
username: str
56+
password: str
57+
58+
59+
class TokenResponse(BaseModel):
60+
access_token: str
61+
token_type: str = "bearer"
62+
63+
64+
# Sample user database (in a real app, you would use a database)
65+
USERS = {
66+
"user1": {"password": "password1", "email": "[email protected]"},
67+
"user2": {"password": "password2", "email": "[email protected]"},
68+
}
69+
70+
71+
@app.post("/login", response_model=TokenResponse)
72+
def login(data: LoginRequest, response: Response):
73+
"""Login endpoint that:
74+
- Returns access_token in the response body (for client to store in memory)
75+
- Sets refresh_token as an HTTP-only cookie (for secure storage).
76+
"""
77+
# Validate credentials
78+
if data.username not in USERS or USERS[data.username]["password"] != data.password:
79+
raise HTTPException(status_code=401, detail="Invalid username or password")
80+
81+
# Create tokens
82+
access_token = auth.create_access_token(uid=data.username)
83+
refresh_token = auth.create_refresh_token(uid=data.username)
84+
85+
# Set ONLY the refresh token in a cookie (with CSRF protection)
86+
# The access token is NOT stored in a cookie - it will be in client memory
87+
auth.set_refresh_cookies(refresh_token, response)
88+
89+
# Return access token in response body
90+
# Client should store this in memory (not localStorage for XSS protection)
91+
return TokenResponse(access_token=access_token)
92+
93+
94+
@app.post("/refresh", response_model=TokenResponse)
95+
async def refresh(request: Request):
96+
"""Refresh endpoint that:
97+
- Reads refresh_token from the HTTP-only cookie (automatically sent by browser)
98+
- Validates the refresh token
99+
- Returns a new access_token in the response body.
100+
101+
For POST requests with cookies, CSRF token must be included in X-CSRF-TOKEN header.
102+
"""
103+
try:
104+
# Get refresh token from COOKIES only (not headers)
105+
# The locations parameter restricts where to look for the token
106+
refresh_token = await auth.get_refresh_token_from_request(
107+
request,
108+
locations=["cookies"], # Only look in cookies
109+
)
110+
111+
# Verify the refresh token (CSRF is verified automatically for cookies)
112+
payload = auth.verify_token(refresh_token, verify_type=True)
113+
114+
# Create a new access token
115+
new_access_token = auth.create_access_token(uid=payload.sub)
116+
117+
# Return in response body (client stores in memory)
118+
return TokenResponse(access_token=new_access_token)
119+
120+
except Exception as e:
121+
raise HTTPException(status_code=401, detail=str(e)) from e
122+
123+
124+
@app.get("/protected")
125+
async def protected_route(request: Request):
126+
"""Protected route that requires access_token in the Authorization header.
127+
128+
Example request:
129+
curl -H "Authorization: Bearer <access_token>" http://localhost:8000/protected
130+
"""
131+
try:
132+
# Get access token from HEADERS only (not cookies)
133+
access_token = await auth.get_access_token_from_request(
134+
request,
135+
locations=["headers"], # Only look in headers
136+
)
137+
138+
# Verify the access token
139+
# No CSRF verification needed for header-based tokens
140+
payload = auth.verify_token(access_token, verify_csrf=False)
141+
142+
# Get user info
143+
username = payload.sub
144+
user_data = USERS.get(username, {})
145+
146+
return {
147+
"message": "Access granted",
148+
"username": username,
149+
"email": user_data.get("email"),
150+
"token_location": access_token.location,
151+
}
152+
153+
except Exception as e:
154+
raise HTTPException(status_code=401, detail=str(e)) from e
155+
156+
157+
@app.post("/protected-action")
158+
async def protected_action(request: Request):
159+
"""Protected POST route that requires access_token in the Authorization header.
160+
161+
This demonstrates that even for POST requests, when using header-based auth,
162+
no CSRF token is needed (CSRF is only a concern for cookie-based auth).
163+
"""
164+
try:
165+
# Get access token from HEADERS only
166+
access_token = await auth.get_access_token_from_request(
167+
request,
168+
locations=["headers"],
169+
)
170+
171+
# Verify the access token (no CSRF for headers)
172+
payload = auth.verify_token(access_token, verify_csrf=False)
173+
174+
return {
175+
"message": "Action performed successfully",
176+
"username": payload.sub,
177+
}
178+
179+
except Exception as e:
180+
raise HTTPException(status_code=401, detail=str(e)) from e
181+
182+
183+
@app.post("/logout")
184+
def logout(response: Response):
185+
"""Logout endpoint that clears the refresh token cookie.
186+
187+
The access token (stored in client memory) will naturally be cleared
188+
when the client discards it or the page is refreshed.
189+
"""
190+
# Only unset refresh cookies (access token is in client memory, not managed by server)
191+
auth.unset_refresh_cookies(response)
192+
return {"message": "Successfully logged out"}
193+
194+
195+
@app.get("/")
196+
def read_root():
197+
"""Public route with API documentation."""
198+
return {
199+
"message": "AuthX Dual Token Location Example",
200+
"description": "Access tokens in headers, refresh tokens in cookies",
201+
"flow": {
202+
"1_login": "POST /login - Returns access_token in body, sets refresh_token cookie",
203+
"2_api_call": "GET /protected - Send access_token in Authorization header",
204+
"3_refresh": "POST /refresh - Refresh token sent via cookie, returns new access_token",
205+
"4_logout": "POST /logout - Clears refresh_token cookie",
206+
},
207+
"example_requests": {
208+
"login": 'curl -X POST -H "Content-Type: application/json" -d \'{"username":"user1","password":"password1"}\' http://localhost:8000/login',
209+
"protected": 'curl -H "Authorization: Bearer <access_token>" http://localhost:8000/protected',
210+
"refresh": 'curl -X POST -b "refresh_token_cookie=<token>" -H "X-CSRF-TOKEN: <csrf_token>" http://localhost:8000/refresh',
211+
"logout": "curl -X POST http://localhost:8000/logout",
212+
},
213+
}
214+
215+
216+
if __name__ == "__main__":
217+
import os
218+
219+
import uvicorn
220+
221+
port = int(os.environ.get("PORT", 8000))
222+
uvicorn.run(app, host="0.0.0.0", port=port)

0 commit comments

Comments
 (0)