Preface #
User authentication is an unavoidable topic when developing web applications. While working on a backend project with FastAPI, I came across JWT1 as an authentication method and found it quite interesting. I did some research and decided to document it here.
What is JWT #
JWT stands for JSON Web Token. Simply put, it’s a token format where the content is encoded JSON, commonly used for identity verification in the web domain.
Typically, a user sends their username and password to the server (we’ll skip OAuth and other third-party authentication for now). After verification, the server issues a Token to the user. This Token contains necessary information such as the issuer, subject (usually the user ID), and expiration time. From then on, the server no longer needs the username and password. The Token alone is sufficient to confirm the user’s identity.
What Problem Does JWT Solve #
You might ask: since the user already has a username and password, why bother with the extra step? Why generate a Token first instead of using credentials directly, like HTTP Basic Authentication2?
There are two main reasons: security and performance.
Security is fairly straightforward. Compared to sending plaintext usernames and passwords in every request, a JWT with a built-in expiration mechanism is clearly more secure.
The second reason, which I think is even more important, is performance. Let’s consider what the username/password authentication process looks like:
- The user sends a request with Base64-encoded username and password.
- The server decodes the request, compares the username and password against the database (passwords are usually stored as hashes), and returns pass or fail.
With only a few requests per second, this design works fine. But for a service handling tens of thousands of requests per second, authentication alone would put enormous pressure on the database. And mature databases are often single-node (scaling databases that handle transactions is no easy feat).
Is it possible to issue a token to the user, and then verify it across server replicas or even different servers, without querying the database? This would make the server truly stateless. Anyone familiar with distributed systems knows how much simpler stateless architectures are.
How JWT Ensures Security #
Since JWT is just Base64-encoded JSON, can a user just make one up and fool the server?
Of course not. JWT uses digital signatures. The last part is generated using a secret key known only to the server, combined with the content of the first two parts. Since this key is only available on the server and it’s virtually impossible to reverse-engineer the key from the content, attackers cannot forge a valid JWT.
JWT Structure #
JWT consists of three parts:
- Header
- Payload
- Signature
All three parts are Base64-encoded strings joined by .. A typical JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQThe decoded header:
{
"alg": "HS256",
"typ": "JWT"
}The decoded payload:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}The last part is the signature, used to verify that the first two parts haven’t been tampered with.
How to Use JWT #
HTTP Header #
Theoretically, as long as you transmit the JWT to the server, you’re done. RFC 75193 doesn’t mandate where JWT must be used. But since it’s a token, the common practice is to include it in the Request Header:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQFastAPI Example #
Here I’m using python-jose4. Install it with pip install "python-jose[cryptography]". You can also use other libraries from https://jwt.io/libraries.
For brevity, I’ll skip the FastAPI boilerplate and only show the core code.
First, create an endpoint to issue tokens:
from fastapi.security import HTTPBasic, HTTPBasicCredentials
http_basic = HTTPBasic()
def create_jwt_access_token(
data: dict,
expires_delta: timedelta,
) -> str:
to_encode = data.copy()
to_encode.update({"exp": datetime.utcnow() + expires_delta})
return jwt.encode(
to_encode,
"jwt_secret", # Replace with your own secret key
algorithm="HS256",
)
@app.post("/auth/issue-new-token")
def issue_new_token(
credentials: HTTPBasicCredentials = Depends(http_basic),
):
username = basic_authentication(credentials) # Verify username & password
access_token = create_jwt_access_token(
data={"sub": username},
expires_delta=timedelta(seconds=1234),
)
return {"access_token": access_token, "token_type": "bearer"}This is a minimal JWT generation endpoint. Note that there’s no rate limiting or error handling here, it’s just a basic demo.
Next, the JWT verification function:
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
http_bearer = HTTPBearer()
def jwt_authentication(
credentials: HTTPAuthorizationCredentials = Depends(http_bearer),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
if credentials.scheme.lower() != "bearer":
raise credentials_exception
return jwt.decode(
credentials.credentials,
"jwt_secret", # Replace with your own secret key
algorithms=["HS256"],
)
except JWTError:
raise credentials_exceptionAdd it as a dependency to any endpoint that requires authentication:
# Option 1: Verify only, don't need payload
@app.get("/api/protected", dependencies=[Depends(jwt_authentication)])
def protected_api():
...
# Option 2: Get payload for further processing
@app.get("/api/protected")
def protected_api(jwt_payload: dict = Depends(jwt_authentication)):
# Process jwt_payload
...Frontend Approach #
Since many components depend on backend APIs that require JWT, it’s common to use Context to manage the token for cross-component access.
When building AuthStateContext.Provider, my approach is:
- Since we use OAuth, JWT arrives from the backend via query string. First check if there’s a new Token in the query string.
- If yes, extract it and store in localStorage.
- If no, proceed to the next step.
- Check if there’s a stored Token in localStorage.
- If yes, verify if it’s still valid.
- If no, or if the previous check failed, proceed to the next step.
- Call a “who am I” endpoint to let the backend verify the JWT (optional, but adds reliability).
- If all checks pass, pass the JWT state to other components via Provider.
- Otherwise, redirect to the appropriate page and prompt the user.
Other components can access the verified Token using useContext(AuthStateContext).
JWT Limitations #
JWT has a few drawbacks to consider when designing your system:
- JWTs are hard to revoke once issued. This is the cost of being stateless.
- Due to point 1, tokens should have short expiration times, meaning the client needs to handle token refresh.
- JWT content is not encrypted by default. Anyone can read it, so don’t put sensitive information inside.
- JWT is recommended to be used over HTTPS.