Take the pain to learn user authentication before you use an external provider
Tags: security, webdev
Published: 2024-03-19 09:01:37

External user authentication is not as simple, secure and cheap as it seems. These are the lessons I learned while implementing user authentication and authorization for my startup [Afterword](https://www.afterword.tech/).

Most startups nowadays rely on external user authentication services called **"Identity as a Service" (IDaaS)** or **"Authentication as a Service" (AuthaaS)**. Even big companies like [OpenAI use auth0](https://twitter.com/kwuchu/status/1641477407180824576). Firebase by Google is also really popular. While I guess it makes sense in the world of “move fast and break things” we have been sold a lot of lies about user authentication by these providers which I would like to dispel.

Developers think outsourcing user authentication to an external provider is easier, more secure, cheaper in the short run but it’s not true. If you don’t understand security well enough you will still store important information like user authentication tokens in localstorage or cookies that can be read by client side javascript. Javascript injection attacks will allow hackers to steal user credentials and pretend to be them, exposing user data and consuming their credits in a Saas application.

## Problems with external user authentication services

- **The False Security of Outsourcing:** Outsourcing user authentication does not automatically ensure security; poor implementation can leave users exposed.
- **Privacy Concerns with Third-Party Data Handling:** Utilizing third-party authentication involves entrusting user login data to another vendor, risking user privacy and data exploitation by competitors.
- **Control Over User Authentication Flow:** Third-party services limit customization and control the flow of user authentication, degrading user experience due to external redirections.
- **Design Constraints with Pre-Designed UI:** Pre-designed user interfaces from third-party services may not align with your site's aesthetics, causing a disjointed experience.
- **Tech Stack Compatibility Issues:** Third-party user authentication may not seamlessly integrate with your specific tech stack.
- **Time Efficiency in Self-Implementation:** Self-implementation of user authentication can be quicker with proper understanding; my experience saw more than a week for third-party setup versus 4-5 days for in-house.
 - Cost of Third-Party Authentication
 - **Hosted Services:** Costs escalate with user growth.
 - **Self-Hosting:** Adds complexity when deploying and contributes to extra costs also.
- **Costs of Advanced Features:** Extra features like social login and multi-factor authentication in external services incur additional expenses.
- **The Risks of Vendor Lock-In:** Dependence on third-party providers exposes you to their changing terms, costs, and risks of vendor lock-in.
- **Challenges in Transitioning to In-House Solutions:** Shifting to an in-house system later can be complex, often necessitating password changes for users, thus increasing friction and dissatisfaction.

## The basics of user authentication and authorization

User authentication and authorization may initially appear challenging and intimidating, but this perception changes once you grasp the underlying mechanisms. Moreover, it's an essential aspect you can't bypass. Despite popular belief, third-party user authentication isn't the panacea it's often made out to be.

In the sections below, I'll guide you through a secure method for implementing user authentication and authorization. While there are other approaches, they fall beyond the scope of this blog post.

First let’s understand the difference between user authentication and user authorization:

1. **User Authentication:** Process of verifying identity of the user. Typically done using username and password.
2. **User Authorization:** Verifying the logged in user when they make a certain request like accessing their saved data or performing any action that the user should be signed in for.

Upon successful user authentication, that is, when they log in, the server issues a cookie containing a token(a unique string). This cookie is sent by the browser with each subsequent request, allowing the server to verify the user's identity and respond to their requests.

There are two primary methods to verify this token:

1. **Database Storage:** Store the token and its corresponding user ID (a unique identifier in the database akin to a primary key) in the database.
2. **JSON Web Token (JWT):** Utilize JWT, a widely supported standard that I will explain below.

## How JSON Web Token works

Many libraries support JWT so you don’t have to implement it yourself but you do need to understand it. One good one for python is **PyJWT.**
`pip install PyJWT`

JWTs are advantageous as they encapsulate user details, such as the user ID, along with other pertinent information chosen by the developer. This data is encrypted using a secret key and stored in the cookie. When the user makes a request, the server decrypts the JWT to identify the user and access their data.

Benefits of using JWT include:

- **Speed:** Eliminates the need for additional database queries to fetch the user ID.
- **Built-in Expiration:** JWTs have an inherent expiry time, enhancing security by limiting the window during which a compromised token could be used.

At Afterword, for instance, access tokens expire after one hour for added user safety.

Example code to create and decode JWT in python using PyJWT:

```
import jwt
from jwt import PyJWTError
from datetime import datetime, timedelta

SECRET_KEY = "Generate a secret key and put it here"
ALGORITHM = "HS256"
TOKEN_EXPIRE_MINUTES = 60

def create_jwt(data: dict):
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire}) 
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

# Verify JWT token
def decode_token(token):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload

    except PyJWTError:
        raise Exception(status_code=401, detail="Could not validate credentials")
    
create_jwt({"user_id":user_id}) #you can also put other user data in this dict as key value pairs
```
To address the issue of token expiration and avoid frequent re-logins, which could detract from user experience, we employ a dual-token approach:

- **Access Token:** Short-lived, used for regular authentication.
- **Refresh Token:** Longer-lived, used to request new access tokens.

The refresh token is only sent to the server when an access token is rejected, not with every request. This strategy minimizes the risk of a refresh token being compromised. For heightened security, the refresh token can be stored in the database and validated against it. Upon user logout, or to prevent misuse, refresh tokens can be either deleted or added to a blacklist, ensuring that outdated, yet valid, tokens cannot be used to gain unauthorized access.

## Storing JWTs (access and refresh tokens) browser side

Since **local storage and other storage methods like IndexedDB** can be accessed by javascript running in the browser they are not a safe place to store user authorization credentials. Hackers can exploit it using **Cross-Site Scripting (XSS)** to inject malicious code and access these tokens.

An alternative to this is the use of **cookies**. While regular cookies can be accessed by JavaScript, **HTTP-only** cookies are more secure as they are **accessible only to the server**. The browser is designed to prevent any JavaScript from reading HTTP-only cookies. Moreover, cookies set by the server should be flagged as **'Secure'** to ensure they are transmitted exclusively over **HTTPS** connections, not **unsecured HTTP**.

Another critical attribute of cookies for security is the **'SameSite'** flag. Setting this flag to either **'Strict' or 'Lax'**, instead of **'None'**, significantly enhances security. This configuration helps prevent the browser from sending the user's cookies in response to **Cross-Site Request Forgery (CSRF) attacks**. When combined with the HTTP-only and Secure flags, setting SameSite to 'Strict' provides robust protection by safeguarding against XSS, CSRF, and ensuring that all data is transmitted securely over HTTPS.

For additional layers of security, the implementation of CSRF tokens can be considered.

Example of setting a secure, HTTP-only and SameSite="Strict" using a fastapi server:

```
from fastapi import FastAPI, Response, Form
from typing import Annotated
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Set up CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=[FRONTEND_URL],
    allow_credentials=True,
    allow_methods=["*"],  # Allows all methods
    allow_headers=["*"],  # Allows all headers
)

TOKEN_EXPIRE_MINUTES = 60

@app.post("/login")
async def login(email: Annotated[str, Form()], password: Annotated[str, Form()], response: Response):
    user = #Retrieve user from database using email
    if user and password == user["password"]: #Don't store password in plain text in a real application
        response.set_cookie("token", value=JWT_token, max_age=TOKEN_EXPIRE_MINUTES*60, httponly=True, secure=True, samesite="Strict", domain="your site domain")
        return True
    else:
        return False
```

This provides a solid foundation in understanding the user authentication and authorization flow, valuable knowledge regardless of whether you opt for an external provider. To develop a fully in-house user authentication and authorization system, more in-depth information is required. Stay tuned for the **second part** of this blog post, where I'll delve into password encryption and the intricacies of sending emails.