DEV Community

Cover image for Protecting statically generated pages using JWT.
MrEraxd
MrEraxd

Posted on

Protecting statically generated pages using JWT.

When using SSG (Static Site Generation), there's no server in the middle of requests and responses, so we've got to authenticate users differently. One way is using frontend middleware, but that's a bit iffy because someone can just turn off JavaScript in their browser and skip it. Turning off JS won't stop the page from showing up since it's all static files.

Solution for it is to authenticate users with Nginx, making sure you can't load a page without logging in first. To do this, Nginx has a cool module that checks for a valid JWT token and can also check required claims. There are a few libraries for it, and one I will use is https://github.com/max-lt/nginx-jwt-module.

In this article I will answer some questions that poped up in my mind when getting into this topic as well as I will show you how to implement such authentication using Nuxt, Fastapi and Nginx.

1. My questions

1.1.: What is JWT?

JWT is a standardized way to transmit information between parties in a compact and secure manner. This information can be verified and trusted, as it is digitally signed. JWTs are often used for authentication and authorization purposes, and they are commonly used in web development as a way to securely transmit information between different parts of an application.

A JWT consists of three parts:

  1. Header: Contains information about how the JWT is encoded, such as the type of the token (JWT) and the signing algorithm being used.
  2. Payload: Contains the claims. Claims are statements about an entity (typically, the user) and additional data. There are three types of claims: registered, public, and private claims.
  3. Signature: To create the signature part you have to take the encoded header, the encoded payload, a secret, the algorithm specified in the header, and sign that.

1.2.: Why to use JWT:

JWT enables the development of stateless APIs, eliminating the need for servers to retain session information about users. By using JWT, the management of multiple backend instances becomes more seamless, thanks to the uniform application of a secret key across all instances, ensuring the accuracy of tokens consistently. In contrast, the use of traditional sessions often involves updating databases, which may result in erroneous or outdated states.

Moreover, JWTs remain valid after creation, obviating the necessity for repetitive database lookups for each request, since all the pertinent user validation information is already embedded within the JWT itself.

1.3.: How to store JWT:

There are several ways to store JSON Web Tokens (JWTs) in an application, and the choice often depends on security requirements, convenience, and the specific use case. Here are common methods:

  1. HTTP Cookies:
    • Advantages: Cookies are automatically sent with each HTTP request, and they can be HTTP-only and secure, making them less susceptible to certain types of attacks.
    • Considerations: Cookies are susceptible to CSRF attacks, and their size is limited. They are also sent with every request, potentially affecting performance.
  2. Local Storage:
    • Advantages: Easy to use, and data persists even if the user closes and reopens the browser.
    • Considerations: Prone to XSS attacks since JavaScript in the same domain can access local storage. Not suitable for sensitive data due to security risks.
  3. Session Storage:
    • Advantages: Similar to local storage but the data is cleared when the session ends (when the browser is closed).
    • Considerations: Vulnerable to XSS attacks, and data does not persist across browser sessions.
  4. In-App Memory:
    • Advantages: JWTs are stored in variables during the runtime of the application, and they are not persisted after a page refresh or when the application is closed.
    • Considerations: Not suitable for long-term storage or scenarios where token persistence is required.
  5. Secure Token Storage (iOS Keychain, Android Keystore):
    • Advantages: For mobile applications, secure storage options provided by the operating system can be utilized.
    • Considerations: Platform-specific, and might be overkill for less sensitive applications.

I'll opt for cookies as the preferred storage method. Their widespread accessibility, biggest security out of them all, and persistent nature make them the ideal choice.

1.4.: Problem with storing JWT in cookie with secure and http-only flags

The initial hiccup we face is JavaScript's inability to access JWT, leading the app to make an extra request for user information. While this might seem like a drawback, it actually doubles as an advantage, as attackers can't snatch the token via an XSS attack on the client side.

The second issue revolves around performance, given that cookies tag along with every request. This includes even image file requests, resulting in our JWT cookie being sent with each one. Fortunately, there's a workaround. Since cookies are stored per domain, we can leverage this by using a distinct domain for fetching resources that aren't sensitive to JWT. Keep in mind that it is possible to hijack cookies in ways different than XSS, you can read more here: https://www.invicti.com/learn/cookie-hijacking/

1.5.: How does app know that user is logged in?

The application doesn't require this information since, thanks to Nginx JWT validation, it's reasonable to assume that a user who can access a page other than the authentication pages is already logged in. This presumption is based on the user having presented a valid JWT tag via cookies.

1.6.: Is data stored in JWT visible to user?

Yes, you can easly decode JWT using decode/encoder on official website: https://jwt.io/.

1.7.: Can user change data in JWT?

Certainly, users can attempt to modify data in a JWT, but without knowledge of the secret key, they would be unable to validate such alterations. For successful validation, all data within the token must remain identical to the state at the time of its signing.

1.8.: How to get user data?

Since we lack access to the JWT on the frontend, utilizing its payload, which contains crucial user data, becomes impossible. A viable workaround involves establishing a new endpoint, e.g., /auth/me, to retrieve user info which we can later store within the application state.

1.9.: What are JWT claims?

JWT claims are the pieces of information contained within a JWT that define the characteristics and attributes of the token. These claims provide relevant information about the entity (usually the user) and additional metadata related to the token itself. Claims are expressed as key-value pairs, where the key represents the claim name and the value represents the corresponding claim value. There are three types of claims in a JWT:

  1. Reserved claims: These are predefined claims that are not mandatory but are recommended to provide useful information. Examples include "iss" (issuer), "exp" (expiration time), "sub" (subject), "aud" (audience), and "iat" (issued at).
  2. Public claims: These are custom claims that are created to share information between parties that agree on using them. Public claims need to be defined in the IANA JSON Web Token Registry or be defined as a URI that contains a collision-resistant namespace.
  3. Private claims: These are custom claims created to share information between parties that agree on using them. They are not defined in any standard or registry and can be used for sharing information that is not part of the public claim set. And this is what we will use for our app.

Here is an example of how a JWT claim might look:

{
  "sub": "1234567890", // Reserved claim for subject
  "exp": 1516239022, // Reserved claim
  "name": "John Doe", // Public claim
  "p_order_r": 1 // Private claim
}
Enter fullscreen mode Exit fullscreen mode

1.10.: How to store permissions in JWT?

In order to utilize permissions efficiently in Nginx and API, without resorting to frequent database checks for every request, it's imperative to store these permissions within the JWT using claims. For instance, consider the following claims that enable a user to both read and edit orders:

{
  "p_orders_read": true,
  "p_orders_write": true
}
Enter fullscreen mode Exit fullscreen mode

Notice usage of p_ as a prefix for permissions. I'm using it so it is easier to disctint them from other claims.

Minimizing the length of the token is essential, as it is transmitted with every request. Although the current example is concise, the length may swiftly accumulate with the introduction of additional permissions, necessitating a different approach. One viable strategy involves substituting the true value with 1 , given that Nginx accepts any non-zero value as a valid claim. This simple adjustment saves three characters for each permission, effectively reducing the overall token length.

{
  "p_orders_read": 1,
  "p_orders_write": 1
}
Enter fullscreen mode Exit fullscreen mode

Let's improve it further by applying linux convention of naming permissions for files:

  • r - read
  • w - write
  • x - execute

we can always extend this list if it is needed. For example when user should be able to edit order but not delete it we can add new letter d .

{
  "p_orders_r": 1,
  "p_orders_w": 1
}
Enter fullscreen mode Exit fullscreen mode

If at this point you are still concerned about your JWT token size I would consider moving to role based authentication instead of permissions.

1.11.: When we need new token?

New token should be generated in following cases:

  • Token Expiration: tokens often have a limited lifespan, defined by an expiration time set in the token's payload. Once the token expires, a new JWT must be generated to continue accessing the protected resources.
  • Claims changes:

1.11.: How to logout user?

To log out a user, you must remove the token from the cookie using the /logout endpoint, as there is no alternative method to accomplish this. Consequently, remote logout of a user is not possible.

1.12.: How to revoke token?

The nature of JWTs makes it challenging to revoke them, primarily due to the absence of a centralized repository capable of storing all tokens. While it's possible to create such a repository, doing so would essentially negate the benefits that initially motivated us to use JWTs.

1.13.: What if token is stolen?

If a JWT token is stolen, the user's sensitive information becomes exposed to potential misuse. Similarly, if sessionIDs are compromised, the user's data is at risk. Unlike sessions, there's no direct mechanism to revoke a stolen JWT token. However, you can render stolen tokens useless by refreshing the secret key. One drawback of this approach is that it will log out all users, necessitating a fresh login. Alternatively, you can consider implementing a temporary blacklist for tokens, which will be elaborated on in the subsequent section.

1.14.: Killswitch module (Blacklisting tokens)

The backend could incorporate a "killswitch" module that introduces temporary additional JWT verification logic. This module would cross-check the token against a database to verify whether it's been blacklisted. This measure should be temporary and solely employed in the event of a token breach. The database will retain blacklisted tokens until their expiration dates, after which the supplementary check can be removed. Blacklisted tokens will find their place in a database such as Redis, a highly efficient in-memory key-value store. Redis facilitates quick and easy verification to determine if a key has been revoked.

1.15.: How to refresh token when claims changes?

Once more, we encounter the constraints of JWT. Without querying the database, we lack a method for monitoring permission changes. If the application is an internal one, a viable solution might involve prompting the user or employer to initiate a logout followed by a login, or simply waiting for the token to expire.

1.16.: How long should be token expiry date?

As short as possible. Some people say that they are setting expiry date for few seconds. I don't see any reason for it. Expiry time should depend on data that it allows user to access. For example when you are using JWT in banking, setting it for few minutes should be correct. But when you are dealing with data not so important setting it for one day or more shouldn't be a problem. Let's take a look at sessions. Some apps like facebook or instagram will set session expiry time for few months and extend it everytime you open the page. Why is nobody telling them that they are doing it wrong? Because they don't. You could even set expiry for few seconds but this is still enough time for attacker to for example reset a password that will give him unlimited time in such stolen account. Setting a month for a cloth shop user should be good, but for shop admin setting it to one day is better solution as they are managing more sensitive information. Also remember that when user opens app we can just generate new token if old one is about to expire. Summing things up: Set as little time as it is acceptable for enduser, noone will use your app if they will have to login every few minutes.

1.17.: Native apps:

In native apps, users usually want to stay logged in, even for a long time. But having a token that lasts too long can be risky. Instead of saving a token for a lengthy period, it's better to store the username and password on the device itself using something like KeyChain on MacOS. This way, when the app is opened, you can just use this info to make a new token.

2. Implementation using Nginx, Nuxt 3, FastAPI

We're building a Nuxt app generated statically, safeguarded with JWT through nginx, along with a Python API also protected with JWT. The setup will include three paths:

/login - That is not protected JWT

/ - Which is "dashboard" this path will be protected just by having JWT

/orders - Which will be protected by JWT and will require claim named "p_orders_r" to be in JWT

Also we will create few API endpoints which are:

/api/auth/login - For initial loggin into app

/api/auth/logout - For loggin out of app (Removing cookie)

/api/auth/me - For getting user data after page refresh

2.0.: Prerequiers

  • Linux/MacOS/WSL for this.
  • Installed Python
  • Installed Nginx on machine/WSL (Not source code)
  • Installed NodeJS

2.1.: Compile nginx module

NOTE: At the end of this section there is ready bash script for you to use, just change nginx version in the script.

To begin, let's configure nginx. We'll employ a JWT handling module, opting for the one found at https://github.com/max-lt/nginx-jwt-module, which aligns perfectly with our requirements. To compile this module, we'll require the nginx source code accessible via https://nginx.org/en/download.html. Simply right-click on the nginx-[version] and copy the download link. Ideally, use the nginx version that's already installed; you can confirm this by running the nginx -v command. In this case, I'll work with version 1.24.0 for demonstration purposes, but feel free to substitute it with your version.

Screenshot from nginx.org with link to download nginx outlined

After selecting correct nginx version download it using wget :

wget https://nginx.org/download/nginx-1.24.0.tar.gz
Enter fullscreen mode Exit fullscreen mode

Now unpack archive using tar :

tar -zxvf nginx-1.24.0.tar.gz
Enter fullscreen mode Exit fullscreen mode

After this is done cd to folder that you've just unziped. Before continuing install requierd libraries:

sudo apt-get install libpcre3 libpcre3-dev zlib1g-dev libssl-dev autoconf libtool pkg-config libjansson-dev cmake check gcc make
Enter fullscreen mode Exit fullscreen mode

Next clone repository with module:

git clone https://github.com/max-lt/nginx-jwt-module.git
Enter fullscreen mode Exit fullscreen mode

And then configure nginx to use our module by runing this command in nginx directory:

./configure --with-compat --add-dynamic-module=../nginx-jwt-module
Enter fullscreen mode Exit fullscreen mode

If everything is correct you will get summary:

Screenshot of configuration summary from configuring nginx

Compile using:

make modules
Enter fullscreen mode Exit fullscreen mode

Here is full script if you don't want to pase commands by yourself:

#!/bin/bash

# Change for your version
NGINX_VERSION="1.24.0"
# Set to true if you want to remove downloaded assets
CLEANUP=true

# Install dependencies
sudo apt-get install -Y libpcre3 libpcre3-dev zlib1g-dev libssl-dev autoconf libtool pkg-config libjansson-dev cmake check gcc make wget tar

# Get nginx source code
wget https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz

# Unpack nginx
tar -zxvf nginx-${NGINX_VERSION}.tar.gz

# Remove archive
rm -rf nginx-${NGINX_VERSION}.tar.gz

# Clone module
git clone https://github.com/max-lt/nginx-jwt-module.git

# CD to nginx dir
cd nginx-${NGINX_VERSION}

# Configure nginx to use module
./configure --with-compat --add-dynamic-module=../nginx-jwt-module

# Compile module
make modules

if $CLEANUP ; then 
  cd ..
  cp ./nginx-${NGINX_VERSION}/objs/ngx_http_auth_jwt_module.so .
  rm -rf nginx-${NGINX_VERSION}
  rm -rf nginx-jwt-module
fi
Enter fullscreen mode Exit fullscreen mode

Now copy created module to nginx dir for easier management:

sudo cp ngx_http_auth_jwt_module.so /etc/nginx/modules/ngx_http_auth_jwt_module.so
Enter fullscreen mode Exit fullscreen mode

2.2.: Configuring nginx to check for JWT

To load module, add this line to nginx.conf file:

load_module /etc/nginx/modules/ngx_http_auth_jwt_module.so;
Enter fullscreen mode Exit fullscreen mode

Create new file under /etc/nginx/sites-available/ I suggest naming it using domain that this file will be responsible for me it will be local.example.com

sudo nano /etc/nginx/sites-available/local.example.com
Enter fullscreen mode Exit fullscreen mode

In this nginx file we will add server that will define logic

# This section will contains all info about server
server {
  # Port that server will listen on
  listen 80;

  # Root path that nginx will search files for
  root /var/www/html;

  # Setup redirection to allowed pages
  # based on error code
  error_page 401 =301 /login;
  error_page 403 =301 /;

  # Name of the domain that server will listen for me it is name of the file
  server_name local.example.com;

  # Key of the JWT check possible values in docs
  # https://github.com/max-lt/nginx-jwt-module/tree/15a170bf10208ecee74d9a22bf1058b36e6502aa
  auth_jwt_key "ultra_secret_key";

  # To enable JWT check for all locations under this server uncomment 
  # value below it is easier to disable check for few pages that don't
  # don't need it than adding it to every that needs we also have to tell
  # module that our cookie will be stored in cookie named AuthToken
  auth_jwt $cookie_AuthToken;

  # Path of api, because we are validating JWT on BE side 
  # there is no need to make additional validation here
  location /api {
    auth_jwt off;
    proxy_pass http://localhost:8000;
  }

  # Location where nuxt stores all transpiled files we also have to let
  # unauthorized users open this folder as they have to open login form.
  location /_nuxt {
    auth_jwt off;
  }

  # Now we have to define locations for requests
  # Location below will be responsible for opening /login path
  # in this example it will be local.example.com/login
  # this path wont be protected by JWT as we want our user to be able to login
  # so we will just return file responsible for that path
  location /login {
    auth_jwt off;
    try_files $uri $uri/ =404;
  }

  # Path / just requiers valid JWT to be present
  location / {
    try_files $uri $uri/ =404;
  }

  # Path /orders will now not require any claims for now
  # we will implement this later in permissions chapter
  location /orders {
    try_files $uri $uri/ =404;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we have to semantically link newly created file to sites-enabled dir. Do it by using:

sudo ln -s /etc/nginx/sites-available/local.example.com /etc/nginx/sites-enabled
Enter fullscreen mode Exit fullscreen mode

Before restarting nginx test config using:

sudo nginx -t
Enter fullscreen mode Exit fullscreen mode

If everything is correct restart nginx (Command can vary depending on your env)

sudo systemctl restart nginx

- or -

sudo service nginx restart
Enter fullscreen mode Exit fullscreen mode

Let's now open login pages

http://local.example.com <- This should give 401 (Authorization Required error)

http://local.example.com/login <- This should give you login page

To test if everything is working we need valid JWT, to get we we will use official JWT website https://jwt.io/ all you have to do is to add your secret key from nginx config to secret input key in website:

Screenshot from jwt.io with secretkey input outlined

now add new claims named p_orders_r and p_orders_w with value 1 .

Screenshot of new private claims added in jwt.io Ensure that you set the expiration time (exp) as a timestamp in seconds rather than milliseconds when utilizing this feature. After entering all the required data, copy the encoded key from the left-hand textarea. Next, open your preferred browser and navigate to the Application tab. Click on Cookies, then select your domain. Create a new cookie, naming it exactly as specified in the nginx configuration - in this case, AuthToken. Use the generated JWT as the value for this cookie.

2.3.: Implementation in Nuxt

I will create new nuxt app using nuxi init . So this is our starting structure:

Structure of folders after using nuxi init

now install packages using npm i . After this is done we have to add few folders:

  • pages
  • components

in components folder create new file (component) named LoginForm.vue with just template and div inside so nuxt will not shout for us, we will modify this component later.

<template>
  <div>Login</div>
</template>
Enter fullscreen mode Exit fullscreen mode

In pages folder create two files, index.vue with regular content like:

<template>
  <div>Hello, you've opened dashboard</div>
</template>
Enter fullscreen mode Exit fullscreen mode

second file is login.vue with our login form component:

<template>
  <LoginForm />
</template>
Enter fullscreen mode Exit fullscreen mode

after this is done we have to modify default app.vue , all we have to do is to replace <NuxtWelcome /> with <NuxtPage /> component so it looks like this:

<template>
  <div>
    <NuxtPage />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Let's now edit LoginForm.vue components so that it contains form that will call backend. I will use composition api because I like it better.

<script lang="ts" setup>
const email = ref('');
const password = ref('');

const data = computed(() => {
  const userInfo = {
    username: username.value,
    password: password.value,
  };

  return JSON.stringify(userInfo);
});

const { execute: login } = useFetch('/auth/login', {
  server: false,
  baseURL: 'http://local.example.com/api',
  body: data,
  method: 'POST',
  immediate: false,
  watch: false,
});
</script>

<template>
  <form
    @submit.prevent="login"
    class="form"
  >
    <label for="email">Email</label>
    <input
      id="email"
      type="text"
      v-model="email"
    />

    <label for="password">Password</label>
    <input
      id="password"
      type="password"
      v-model="password"
    />
    <button>SUBMIT</button>
  </form>
</template>

<style>
.form {
  display: flex;
  flex-flow: column;
  width: 320px;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Your finished structure should look like this:

Finished structure of frontend part

Last but not least is to generate static pages, to do it use npm run generate command in your terminal. After this is done you will have your static files generated in dist folder that was created with this command. Those files should be moved to /var/www/html/

2.4.: Backend implementation (Python)

DISCLAIMER: I'm not python delevoper I just wanted to do example implementation, many thing could be written better or different way but this wasn't my goal.

First we have to install some python dependencies those are:

  • FastAPI
  • Jose

To do it use pip install command

pip install fastapi jose typing uvicorn pydantic
Enter fullscreen mode Exit fullscreen mode

Create main.py file with content below:

from fastapi import FastAPI, Response, Request, HTTPException, Depends
from jose import jwt
from time import time
from pydantic import BaseModel

# Set custom path for docs so we can open it without
# adding another path in nginx conf
app = FastAPI(docs_url="/api/docs", openapi_url="/api/docs/openapi.json")


# Definition of user in database it is different
# because we will add permissions to this model
class User(BaseModel):
    username: str
    password: str


# Definition of data that will come from FE
class LoginUser(BaseModel):
    username: str
    password: str


# This is the same key that you are using in nginx
# it will be used to create and check JWT
JWT_SECRET = "ultra_secret_key"

# Name of the cookie that JWT will be stored at
# this is the same as in nginx
COOKIE_NAME = "AuthToken"

# Exipiry time for token
EXPIRY_TIME_IN_MINUTES = 10

# Base user information, normally this would be stored in database
db_user: User = {
    "username": "kkotfisz",
    "password": "hashed_password",
}


# Generate JWT with requierd claims
async def generate_token(user: User):
    return jwt.encode(
        {
            "sub": user["username"],
            "iat": int(time()),
            "exp": int(time() + EXPIRY_TIME_IN_MINUTES * 60),
        },
        JWT_SECRET,
    )

# For now we only extract username from JWT
# later on we will add support for permissions
async def get_user(claims: Annotated[str, Depends(validate_token)]):
    return {"username": claims["sub"]}


# This function should validate token
# using secret key and check expiry date
# If token is valid and not expired return token
# except throw an error
async def validate_token(request: Request):
    try:
        return jwt.decode(request.cookies.get(COOKIE_NAME), JWT_SECRET)
    except:
        raise HTTPException(status_code=401, detail="Provide valid JWT")


# This is path when we will generate access token, this is the place when all
# authentication will happen, we should add here checks for user existance
# password correctnes
@app.post("/api/auth/login")
async def login(response: Response, credentials: LoginUser):
    if (
        credentials.username != db_user["username"]
        or credentials.password != db_user["password"]
    ):
        raise HTTPException(status_code=400, detail="Wrong credentials")

    response.set_cookie(
        key=COOKIE_NAME, value=await generate_token(db_user), httponly=True
    )


# On logout we just have to remove cookie
@app.post("/api/auth/logout")
async def logout(response: Response):
    response.delete_cookie(key=COOKIE_NAME)


# Path that will be availabe only after presenting valid JWT
# it does not check for any specific permissions
@app.get("/api/auth/me", dependencies=[Depends(validate_token)])
async def logged_user():
    return "Data available without permissions"


Enter fullscreen mode Exit fullscreen mode

This is all we need to let users log in (generate and save JWT in cookies) and log out (remove the cookie). We've also set up a route that only needs a valid JWT token, no special permissions required. To get started with the app, just do this:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

If you've not changed settings docs should be available under http://localhost:8000/docs path. After opening it you should get those three paths:

Screenshot from fastapi docs

You can test them, by clicking on path and then "try it out" button. First try path /api/auth/me and check the response. You should get error:

{
  "detail": "Provide valid JWT"
}
Enter fullscreen mode Exit fullscreen mode

This means that our JWT validation works, now login with /api/auth/login path and check this path again, this time your response will be:

{
  "username": "kkotfisz"
}
Enter fullscreen mode Exit fullscreen mode

also in your cookie tab you can now see AuthCookie that contains your fresh JWT. With this setup you have working login/logout system and know how to implement path that require valid JWT. That is all for now.

2.5.: Implementing permissions (Nginx)

We have to extend our nginx configuration from earlier, all we have to do is to add auth_jwt_require with $jwt_claim_[claim_name] to /orders path. If you have to check specifica value you can map a claim to variable.

# If you want to check for specifcic value inside claim you
# can use map to do so use it like this
# map $jwt_claim_[name-of-claim] $[internal_variable_name] {
#   \"[value of this claim]\" 1
# }
# I will not use it as as default it expects any non 0 value
map $jwt_claim_p_orders_r $jwt_p_orders_r {
   \"1\" 1;
}

# This section will contains all info about server
server {
  # Previous config
  # ...

  # Path /orders will require JWT to contain claim named
  # orders_r to open such path to do so just add
  # auth_jwt_require $[name_of_variable(s)]
  location /orders {
    auth_jwt_require $jwt_claim_p_orders_r;
    try_files $uri $uri/ =404;
  }
}
Enter fullscreen mode Exit fullscreen mode

That is basically it for nginx config.

2.6.: Implementing permissions (Python)

First things first, let's add some permissions to our user, they will be: p_orders_r and p_orders_r . To do so we have to edit User model and db_user variable, now it will look like this:

class User(BaseModel):
    username: str
    password: str
    permissions: list[str]


db_user: User = {
    "username": "kkotfisz",
    "password": "hashed_password",
    "permissions": ["p_orders_r", "p_orders_w"]
}
Enter fullscreen mode Exit fullscreen mode

This is done, now we have to alter our function that generates token so it will contain our new permissions:

# Generate JWT with requierd claims
async def generate_token(user: User):
    baseJWT = {
        "sub": user["username"],
        "iat": int(time()),
        "exp": int(time() + EXPIRY_TIME_IN_MINUTES * 60),
    }

    for permission in user["permissions"]:
        # We have to set any non zero value
        baseJWT[permission] = 1

    return jwt.encode(
        baseJWT,
        JWT_SECRET,
    )
Enter fullscreen mode Exit fullscreen mode

NOTE: Remember that we are working on assumption that permission claims are those with p_ prefix.

After new tokens contain all new data we have to alter login path so that it responds with user permissions to our app. Now it will look like this:

# This is path when we will generate access token, this is the place when all
# authentication will happen, we should add here checks for user existance
# password correctnes as return it will give user permissions and set cookie
@app.post("/api/auth/login")
async def login(response: Response, credentials: LoginUser):
    if (
        credentials.username != db_user["username"]
        or credentials.password != db_user["password"]
    ):
        raise HTTPException(status_code=400, detail="Wrong credentials")

    response.set_cookie(
        key=COOKIE_NAME, value=await generate_token(db_user), httponly=True
    )

    return {"username": db_user["username"], "permissions": db_user["permissions"]}
Enter fullscreen mode Exit fullscreen mode

We should also alter get_user function so that /api/auth/me path so that it will also return permissions. Now it will look like this:

# For now we only extract username from JWT
# we are working on assumption that permissions
# are claims with `p_` prefix
async def get_user(claims: Annotated[str, Depends(validate_token)]):
    permissions = []

    for claim in claims:
        if "p_" in claim:
            permissions.append(claim)

    return {"username": claims["sub"], "permissions": permissions}
Enter fullscreen mode Exit fullscreen mode

Now both /api/auth/me and /api/auth/login should respond with username and permissions.

After aplementation of permissions let's create a path that will require p_orders_r permission to be able to read orders. Because I wanted to do it with as less as code inside route I decided to do it with Depends, but depends have problem that you cannot pass parameter to dependant function because it will allow api user to send required permission with request and this is bad idea. To prevent it we can use class with __call__ method. More info here: https://fastapi.tiangolo.com/advanced/advanced-dependencies/. So let's create such class:

# This is class that will test if user have required
# permission for this path, in the constructor we assign
# name of the required permission and then raise exception
# Before checking permission we will validate token to be
# sure it is not expired/modified
class PermissionChecker:
    def __init__(
        self,
        requierd_permissions: list[str],
    ):
        self.requierd_permissions = requierd_permissions

    def __call__(
        self,
        auth_token: Annotated[dict | None, Depends(validate_token)],
    ):
        for required_permission in self.requierd_permissions:
            if required_permission not in auth_token:
                raise HTTPException(status_code=400, detail="Wrong credentials")
Enter fullscreen mode Exit fullscreen mode

Let's use this class in new /api/orders path:

# User will be able to get data from this endpoint only
# after presenting valid JWT with `p_orders_r` claim
@app.get(
    "/api/orders",
    dependencies=[Depends(PermissionChecker(["p_orders_r"]))],
)
async def orders():
    return {"orders": ["order1", "order2"]}
Enter fullscreen mode Exit fullscreen mode

That is all for python, you can play around by yourself with it.

2.7.: Implementing permissions (Nuxt)

We will start by creating new composable named useAuth.ts in /composable folder. In this composable we will store user using useState to keep data between route changes. We also have to provide some login function so that we are able to actually proceed. This is content of this file:

export default function (
  loginData: { username: string; password: string } | null = null
) {
  // Create a state that will hold our user and provide
  // it to whole app
  const user = useState(
    "user",
    (): { username: string; permissions: string[] } | void => {}
  );

  // This is shortcut for checking if user is authenticated
  const isAuthenticated = computed(() => user.value !== undefined);

  // Function that will allow us to login user
  const { execute: login } = useFetch("/auth/login", {
    server: false,
    baseURL: "http://local.example.com/api",
    body: loginData,
    method: "POST",
    immediate: false,
    watch: false,
    onResponse(data) {
      if (data.response.status === 200) {
        user.value = data.response._data;
        navigateTo("/");
      }
    },
  });

  // After user is logged in (contain valid JWT in cookie)
  // he can open many pages but because we are not using
  // any persistant storage we have to refetch user every
  // time page is refreshed/closed opened again
  const { execute: refetchUser } = useFetch("/auth/me", {
    server: false,
    immediate: false,
    baseURL: "http://local.example.com/api",
    onResponse(data) {
      user.value = data.response._data;
    },
  });

  // Function that will test requierd permissions agains
  // permissions that user have
  function havePerms(requiredPermissions: string[]) {
    if (!requiredPermissions) {
      return true;
    }

    return requiredPermissions?.every((requiredPerm) =>
      user.value?.permissions?.includes(requiredPerm)
    );
  }

  return { login, user, havePerms, isAuthenticated, refetchUser };
}
Enter fullscreen mode Exit fullscreen mode

After this one is created we can edit LoginForm.vue so that it now uses new login method provided by this composable. This is very easy change, we just have to remove fetch function and just replace it with useAuth .

<script lang="ts" setup>
const username = ref('');
const password = ref('');

const data = computed(() => {
  const userInfo = {
    username: username.value,
    password: password.value,
  };

  return JSON.stringify(userInfo);
});

const { user, login } = useAuth(data);
</script>

<template>
  <form
    @submit.prevent="login"
    class="form"
  >
    <label for="username">Username</label>
    <input
      id="username"
      type="text"
      v-model="username"
    />

    <label for="password">Password</label>
    <input
      id="password"
      type="password"
      v-model="password"
    />
    <button>SUBMIT</button>

    <div>User {{ user }}</div>
  </form>
</template>

<style>
.form {
  display: flex;
  flex-flow: column;
  width: 320px;
}
</style>
Enter fullscreen mode Exit fullscreen mode

So now you can login using new form and composable, let's now create middleware which will check if user can proceed to specific route (Have required permissions) and if not then redirect to dashboard. Create file auth.global.ts in /middleware folder. .global indicates that middleware will run on every route change.

 export default defineNuxtRouteMiddleware(async (to) => {
  // This line prevents middleware from running when site
  // is generated with `npm run generate`
  if (process.server) return;

  // Import data from useAuth composable
  const { isAuthenticated, refetchUser, havePerms } = useAuth();

  // Because we are storing user in app memory we will
  // have to refetch user on each page refresh
  // NOTE: Changing path with vue-router does not count
  // as page refresh
  if (to.path !== "/login" && !isAuthenticated.value) {
    await refetchUser();
  }

  // Extract permissions from page meta
  const routePermissions = to.meta?.permissions as string[];

  // If user do not have required perms then navigate to /
  if (!havePerms(routePermissions)) return navigateTo("/");
});
Enter fullscreen mode Exit fullscreen mode

After this middleware is added we can denfine permissions required for specific page in its metadata. Create new orders.vue in /pages folder with below content.

<script setup lang="ts">
  definePageMeta({
    permissions: ["p_orders_r"],
  });
</script>

<template><div>Orders path</div></template>
Enter fullscreen mode Exit fullscreen mode

You can also choose to display/not display specific element on the page if user have required permissions. Just add v-if to element with havePerms(['PERMISSION']) method as value. Also don't forget to use composable in setup.

<script setup lang="ts">
  const { user, havePerms, isAuthenticated } = useAuth();
</script>

<template>
  <div>
    Hello {{ user }}, you've opened dashboard

    <NuxtLink
      v-if="havePerms(['p_orders_r'])"
      to="orders"
    >
      Orders
    </NuxtLink>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Now remember to generate page using npm run generate and move files from dist folder to /var/www/html.

3. Summary

By now, you should be familiar with the basics of JWT, and I hope I've addressed all your questions. It's worth noting that this article doesn't cover the entire topic, but it does provide a glimpse into the basic pros and cons of using JWT. Additionally, I've demonstrated how to secure statically generated pages/apps using Nginx, along with a basic implementation of the JWT workflow in Nuxt and FastAPI. At this point, you should have a functioning statically generated app under local.example.com, as well as an API under local.example.com/api that is protected with JWT.

I've also created git repository for all the code here: https://github.com/MrEraxd/SSG-JWT-Auth

If you have any questions or suggestions for improvement, feel free to share your thoughts in the comments! I'm open to feedback on the article as a whole.

4. Links / Resources

https://fastapi.tiangolo.com/

https://github.com/max-lt/nginx-jwt-module

https://fastapi.tiangolo.com/tutorial/security/first-steps/

https://indominusbyte.github.io/fastapi-jwt-auth/usage/jwt-in-cookies/

https://swagger.io/docs/specification/authentication/cookie-authentication/

https://fastapi.tiangolo.com/tutorial/dependencies/global-dependencies/

https://itsjoshcampos.codes/fast-api-api-key-authorization

https://github.com/dystcz/nuxt-permissions

https://sidebase.io/nuxt-auth/getting-started

https://gist.github.com/soulmachine/b368ce7292ddd7f91c15accccc02b8df

https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims

https://journals.sagepub.com/doi/full/10.1177/1550147718801535

Top comments (0)