DEV Community

Kanstantsin
Kanstantsin

Posted on

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
):
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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})"
Enter fullscreen mode Exit fullscreen mode

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):
        ...
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
delta_executar_5056b64859 profile image
Delta Executar

Awesome breakdown! 🎯 Really love how you're thinking through the tradeoffs between client-side friction and security — especially when building solo, where simplicity matters. Using cookies with withCredentials and offloading token management to the backend is a smart move for reducing complexity on the frontend. Also, customizing the FastAPI router for declarative auth is 🔥 — super clean and scalable

Collapse
 
healfy profile image
Kanstantsin

Thank you, glad to hear that someone finds it good choice)