First Step Building Solo: Authentication
In Part 1, I wrote about how I transitioned from working with a team to building solo. Once I made that decision, I needed to start somewhere - and for me, that was authentication.
This post covers how I approached it using FastAPI, why I avoided localStorage, and how I made authentication declarative with a custom router.
There are many ways to handle authentication, but I chose the most popular and relatively reliable approach: JWT auth. The spec generally assumes two endpoints:
- One to get an access + refresh token
- One to get a new access token if the previous one has expired
The client needs to store these tokens somewhere and send them with each request—usually in headers. They're often stored in localStorage, which isn’t very secure.
But I wasn’t as concerned with security (this isn’t a banking app) as I was with client-side friction. So I decided: let’s store everything in cookies, and have the backend manage all the token logic.
The client doesn’t need to know anything about tokens
The frontend code is super simple, I use axios:
export class ApiClient implements BackendClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_URL,
paramsSerializer: {
indexes: null,
},
});
this.client.defaults.headers.post["Content-Type"] = "application/json";
this.client.defaults.withCredentials = true;
this.client.defaults.maxRedirects = 1;
}
}
All you need is withCredentials = true.
- Cookies have CORS restrictions
- Different domains can cause trouble
- You'll likely need CSRF protection
- The backend implementation becomes more involved
My main programming language is Python, so I chose FastAPI. The good (or bad) news is—it doesn't include built-in authentication. I didn’t want to use third-party libraries either—they're often poorly maintained and cause more problems than they solve.
You can use FastAPI’s Depends and add middleware to specific routers, but:
- It locks down all endpoints in that router
- It's not very flexible or intuitive when you need fine-grained control
I also wanted a declarative syntax, so I could explicitly say whether an endpoint requires authentication or not. That’s why I decided to modify the API Router slightly to implement what I had in mind.
I needed a middleware that:
- Parses tokens from the request
- Validates them
- Issues a new access token if needed
- Verifies that the user exists
- Attaches the user’s info to the request state
Usage example:
@users_router.get("/subscription_info", auth_required=True)
async def get_subscription_info(
user: User = Depends(get_user),
sub_service: SubscriptionService = Depends(get_subscription_service),
):
...
Here's what the middleware looks like:
class AuthMiddleware(BaseHTTPMiddleware):
settings: AuthSettings
_routes: List[CustomRoute]
def __init__(self, app: ASGIApp, settings: AuthSettings, routes: List[HarmoniAIRoute]):
super().__init__(app)
self.settings = settings
self.app = app
self._routes = routes
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
for route in self._routes:
# Skip non-API paths
if not request.url.path.startswith("/api/v1"):
return await call_next(request)
# Strip prefix to compare route pattern
prefix, path = request.url.path.split("/api/v1")
if route.path_regex.match(path) and route.auth_required:
service = AuthService(self.settings)
# Extract tokens from cookies
access, refresh = self.get_tokens(request)
if not refresh:
# No refresh token, reject request
return JSONResponse(
status_code=401,
content={
"detail": "Unauthorized",
"error_type": ErrorType.unauthorized,
},
)
# Authenticate and refresh tokens if needed
auth_result = await service.authenticate(access, refresh)
# This checks token validity, fetches the user,
# and optionally issues a new access token
# Attach user info to request state
request.state.app_state.user = RequestUser(
user_id=auth_result.user_id,
)
# If token hasn't changed, continue
if access == auth_result.access:
return await call_next(request)
# Otherwise, update access token in response cookie
response = await call_next(request)
response.set_cookie(
key=TokenTypes.ACCESS,
value=auth_result.access,
httponly=True,
expires=self.settings.authentication.access_token_expires,
secure=self.settings.authentication.secure,
)
return response
# If no auth required or no route match
return await call_next(request)
@staticmethod
def get_tokens(request: Request) -> Tuple[str | None, str | None]:
token = request.cookies.get(TokenTypes.ACCESS)
refresh_token = request.cookies.get(TokenTypes.REFRESH)
return token, refresh_token
Custom Route:
class CustomRoute(APIRoute):
auth_required: bool
def __init__(self, path: str, endpoint: Callable[..., Any], *, auth_required: bool = False, **kwargs):
super().__init__(path, endpoint, **kwargs)
self.auth_required = auth_required
def __repr__(self) -> str:
class_name = self.__class__.__name__
methods = sorted(self.methods or [])
return f"{class_name}(path={self.path!r}, name={self.name!r}, methods={methods!r}, auth_required={self.auth_required!r})"
Custom Router:
class CustomRouter(APIRouter):
routes: list[BaseRoute]
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.route_class = CustomRoute
def __iter__(self):
return self.routes.__iter__()
def put(self, path: str, *, auth_required: bool = True, **kwargs):
return self.api_route(path=path, auth_required=auth_required, methods=["PUT"], **kwargs)
def delete(self, path: str, *, auth_required: bool = True, **kwargs):
return self.api_route(path=path, auth_required=auth_required, methods=["DELETE"], **kwargs)
def patch(self, path: str, *, auth_required: bool = True, **kwargs):
return self.api_route(path=path, auth_required=auth_required, methods=["PATCH"], **kwargs)
def get(self, path: str, *, auth_required: bool = True, **kwargs):
return self.api_route(path=path, auth_required=auth_required, methods=["GET"], **kwargs)
def post(self, path: str, *, auth_required: bool = True, **kwargs):
return self.api_route(path=path, auth_required=auth_required, methods=["POST"], **kwargs)
def api_route(self, path: str, *, auth_required: bool = True, **kwargs):
...
def add_api_route(self, path: str, endpoint: Callable[..., Any], *, auth_required: bool = False, **kwargs):
...
def include_router(self, router: Union["APIRouter", "CustomRouter"], *, **kwargs):
...
Yes, I had to pass a new parameter (auth_required) through several layers. And yes, there’s some duplication. But the result is:
- Transparent token management
- Zero frontend token logic
- Full backend control
- Clean, declarative route configuration
Final Thoughts
Handling authentication myself gave me more control, forced me to think through the user flow, and set the tone for how I want the rest of the system to work: clean, minimal, and backend-driven.
There’s probably a cleaner way to do parts of this. I might revisit it later. But for now, it works, and it keeps the frontend lightweight.