Using Clerk authentication in Rust

Cover image

This two-part article covers how we can build an Issue tracking application using React for the frontend, and Shuttle and Actix Web for the backend. It also uses Clerk for authentication in the frontend and protecting the Rest APIs in the backend.

This article covers the Rust backend, and the second part covers the React frontend.

Here is the source code of the complete project if you want to follow along, and a link to the demo.

To start this project we will use the Clerk template from Shuttle’s example repository that has a barebone setup of Shuttle, Actix Web, Vite (React), and Clerk.

The backend uses the clerk-rs community crate to create a connection between the backend application and Clerk project. This crate has many different APIs for different use cases. In the template there is an endpoint to fetch all user records using the clerk-rs user API. We can easily verify a user's identity when trying to access protected routes using the ClerkMiddleware from the crate.

We will use Postgres for the database which will run in a docker container so make sure you also have docker installed in your local machine.

Let's get started

First, we need to clone the template from the Shuttle’s example repository using Shuttle CLI:

cargo shuttle init --from shuttle-hq/shuttle-examples --subfolder actix-web/clerk

You will get a few prompts to create a new project using this template. Make sure you provide a unique name because when you deploy this project on Shuttle this project name will become the sub-domain. And once a name is taken it cannot be used for any other projects

After that, cd into the project directory.

Setup a new application in Clerk

Head on to Clerk’s official website, sign in or sign up if you don’t have an account, and create a new project in the dashboard. Give a name to your project and select the providers using which users can sign-in or sign-up in your application.

After that, you will get a Publishable Key and a Secret key which we will use later in our application.

clerk setup

Build the backend

Now we can start updating the existing backend code to write and build the Rest APIs for our application.

cd into the backend, and rename the Secrets.example.toml file to Secrets.toml:

cd backend
mv Secrets.example.toml Secrets.toml

Copy the Secret key from the Clerk's dashboard and add it to the Secrets.toml file:

CLERK_SECRET_KEY = "sk_test_vsWrxxxxxxxxxxxxxxxxxxxxxxxxxtfTr"

⚠️ Do not commit this file, so make sure you have added this file in the .gitignore.

Now, we need to write a new migration in a schema.sql file which will create a new issues table in our Postgres database.

Create schema.sql and add the schema:

CREATE TABLE IF NOT EXISTS issues (
  id SERIAL PRIMARY KEY,
  title VARCHAR(100) NOT NULL,
  description VARCHAR(300) NOT NULL,
  status VARCHAR(10) NOT NULL,
  label VARCHAR(10) NOT NULL,
  author VARCHAR NOT NULL
);

We can migrate this schema using SQLX. So add the SQLX crate to your dependency:

cargo add sqlx

To connect to a shared Postgres database managed by Shuttle you need to add the crate with features specific to Postgres and SQLX to your dependencies:

cargo add shuttle-shared-db -F shuttle-shared-db/postgres -F shuttle-shared-db/sqlx

Connect to the shared Postgres DB

Let’s edit the main.rs file.

First, we need to bring SQLX into the scope:

use sqlx::{Executor, FromRow, PgPool};

Add the pool field to the AppState struct to share the Postgres pool instance in our whole application:

struct AppState {
    client: Clerk,
    pool: PgPool,
}

Next, pass the #[shuttle_shared_db::Postgres] pool: PgPool argument to your shuttle_runtime::main function and connect to Postgres using SQLX connect pool PgPool.

#[shuttle_runtime::main]
async fn actix_web(
    #[shuttle_secrets::Secrets] secrets: SecretStore,
    #[shuttle_shared_db::Postgres] pool: PgPool,
) -> ShuttleActixWeb<impl FnOnce(&mut ServiceConfig) + Send + Clone + 'static> {
    // DB Pool
    pool.execute(include_str!("../schema.sql"))
        .await
        .map_err(CustomError::new)?;

    // Clerk integration
    let clerk_secret_key = secrets
        .get("CLERK_SECRET_KEY")
        .expect("Clerk Secret key is not set");
    let clerk_config = ClerkConfiguration::new(None, None, Some(clerk_secret_key), None);
    let client = Clerk::new(clerk_config.clone());

    // Create new app state
    let state = web::Data::new(AppState { client, pool });

    let app_config = move |cfg: &mut ServiceConfig| {
        cfg.service(
            web::scope("/api")
                .wrap(ClerkMiddleware::new(clerk_config, None, true))
                .service(get_user)
        )
        .service(actix_files::Files::new("/", "./frontend/dist").index_file("index.html"))
        .app_data(state);
    };

    Ok(app_config.into())
}

The above function call will create the Postgres pool, and then SQLX will migrate the schema we have created before. The Clerk configuration is already defined in the template for us which uses the CLERK_SECRET_KEY we got from the Clerk project we have created earlier.

The Clerk middleware protects all the paths under /api, so that only signed in users can access them.

Try running the application using Shuttle CLI:

cargo shuttle run

🗨️ Make sure docker is running in your local machine because after you run the above command Shuttle will pull the Postgres docker image and start a docker container.

The backend application can now query the Postgres database using SQLX. Let's create some CRUD endpoints for our application.

Issue structs

To define the fields of an issue we will create an Issue struct that is used to define the response and request payload of our Read, Update, and Delete requests, and a NewIssue struct for the Create requests:

#[derive(Serialize, Deserialize, FromRow)]
struct Issue {
    id: i32,
    title: String,
    description: String,
    status: String,
    label: String,
    author: String,
}

#[derive(Serialize, Deserialize, FromRow)]
struct NewIssue {
    title: String,
    description: String,
    status: String,
    label: String,
    author: String,
}

Extract the Clerk JWT claim from a request

In some of the endpoints we need to check for the user's authorization. For that we will look for a user id which can be found in the JWT claim sub property:

async fn get_jwt_claim(service_request: &ServiceRequest, clerk_client: &Clerk) -> Option<ClerkJwt> {
    let claim = clerk_authorize(service_request, clerk_client, true).await;

    match claim {
        Ok(value) => Some(value.1),
        Err(_) => None,
    }
}

CRUD endpoints

Add the create issue endpoint which can query our database to insert a new issue into the table like this:

#[post("/issue")]
async fn add_issue(payload: web::Json<NewIssue>, state: web::Data<AppState>) -> impl Responder {
    let create_query: Result<Issue, sqlx::Error> = sqlx::query_as(
        "INSERT INTO issues (title, description, status, label, author) VALUES ($1, $2, $3, $4, $5) RETURNING *"
    )
    .bind(&payload.title)
    .bind(&payload.description)
    .bind(&payload.status)
    .bind(&payload.label)
    .bind(&payload.author)
    .fetch_one(&state.pool)
    .await;

    if create_query.is_err() {
        return HttpResponse::InternalServerError().json(serde_json::json!({
            "status":"FAILED",
            "message":"Failed to create an issue"
        }));
    }

    HttpResponse::Ok().json(serde_json::json!({
        "status":"SUCCESS",
        "message":"Created the issue successfully"
    }))
}

This endpoint will retrieve all the issues from the database:

#[get("/issues")]
async fn get_issues(state: web::Data<AppState>) -> impl Responder {
    let query: Result<Vec<Issue>, sqlx::Error> = sqlx::query_as("SELECT * FROM issues")
        .fetch_all(&state.pool)
        .await;

    let issues = match query {
        Ok(value) => value,
        Err(e) => {
            return HttpResponse::InternalServerError().json(serde_json::json!({
                "status": "FAILED",
                "message": e.to_string(),
            }));
        }
    };

    HttpResponse::Ok().json(issues)
}

This endpoint function will extract the issue_id slug from the path using the Path extractor from Actix Web and query our database where the issue id is equal to the issue_id slug of the path.

#[get("/issue/{issue_id}")]
async fn get_issue(state: web::Data<AppState>, path: web::Path<i32>) -> impl Responder {
    let issue_id = path.into_inner();

    let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
        .bind(issue_id)
        .fetch_one(&state.pool)
        .await;

    let issue = match query {
        Ok(value) => value,
        Err(_) => {
            return HttpResponse::InternalServerError().json(serde_json::json!({
                "status":"FAILED",
                "message":"Something went wrong."
            }));
        }
    };

    HttpResponse::Ok().json(serde_json::json!({
        "status": "SUCCESS",
        "data": issue,
    }))
}

To update an issue by id we will use slug from the path which will contain the issue_id to query our database and update the record if it exists in the database. Before updating the record we can query if the record exists in the database or not:

#[patch("/issue/{issue_id}")]
async fn update_issue(
    payload: web::Json<NewIssue>,
    state: web::Data<AppState>,
    path: web::Path<i32>,
    req: HttpRequest,
) -> impl Responder {
    let issue_id = path.into_inner();

    let service_req = ServiceRequest::from_request(req);

    let claim = get_jwt_claim(&service_req, &state.client).await;

    if claim.is_none() {
        return HttpResponse::Forbidden().json(serde_json::json!({
            "status":"FAILED",
            "message":"Not authorized to update the issue."
        }));
    }

    let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
        .bind(issue_id)
        .fetch_one(&state.pool)
        .await;

    match query {
        Ok(issue) => {
            if issue.author != claim.unwrap().sub {
                return HttpResponse::Unauthorized().json(serde_json::json!({
                    "status":"FAILED",
                    "message":"Not authorized to update the issue."
                }));
            }
        }
        Err(_) => {
            return HttpResponse::NotFound().json(serde_json::json!({
                "status":"FAILED",
                "message":"Issue does not exist."
            }));
        }
    }

    let update_query: Result<Issue, sqlx::Error> = sqlx::query_as(
        "UPDATE issues SET title=$1, description=$2, status=$3, label=$4 WHERE id=$5",
    )
    .bind(&payload.title)
    .bind(&payload.description)
    .bind(&payload.status)
    .bind(&payload.label)
    .bind(issue_id)
    .fetch_one(&state.pool)
    .await;

    if update_query.is_err() {
        return HttpResponse::InternalServerError().json(serde_json::json!({
            "status":"FAILED",
            "message":"Failed to update the issue"
        }));
    }

    HttpResponse::Ok().json(serde_json::json!({
        "status": "SUCCESS",
        "message":"Updated successfully"
    }))
}

This endpoint is for deleting the issue by id from the database:

#[delete("/issue/{issue_id}")]
async fn delete_issue(
    state: web::Data<AppState>,
    path: web::Path<i32>,
    req: HttpRequest,
) -> impl Responder {
    let service_req = ServiceRequest::from_request(req);

    let claim = get_jwt_claim(&service_req, &state.client).await;

    if claim.is_none() {
        return HttpResponse::Forbidden().json(serde_json::json!({
            "status":"FAILED",
            "message":"Not authorized to delete the issue."
        }));
    }

    let issue_id = path.into_inner();

    let query: Result<Issue, sqlx::Error> = sqlx::query_as("SELECT * FROM issues WHERE id=$1")
        .bind(issue_id)
        .fetch_one(&state.pool)
        .await;

    match query {
        Ok(issue) => {
            if issue.author != claim.unwrap().sub {
                return HttpResponse::Unauthorized().json(serde_json::json!({
                    "status":"FAILED",
                    "message":"No authorized to delete the issue."
                }));
            }
        }
        Err(_) => {
            return HttpResponse::NotFound().json(serde_json::json!({
                "status":"FAILED",
                "message":"Issue does not exist."
            }));
        }
    }

    let delete_query: Result<Issue, sqlx::Error> = sqlx::query_as("DELETE FROM issues WHERE id=$1")
        .bind(issue_id)
        .fetch_one(&state.pool)
        .await;

    if delete_query.is_err() {
        return HttpResponse::InternalServerError().json(serde_json::json!({
            "status":"FAILED",
            "message":"Failed to delete the issue"
        }));
    }

    HttpResponse::Ok().json(serde_json::json!({
        "status": "SUCCESS",
        "message":"Deleted successfully"
    }))
}

User endpoints

The template provides a get_user handler function and a UserModel struct. We will edit the handler function to use the get_jwt_claim function:

#[get("/user/me")]
async fn get_user(state: web::Data<AppState>, req: HttpRequest) -> impl Responder {
    let service_req = ServiceRequest::from_request(req);

    let claim = get_jwt_claim(&service_req, &state.client).await;

    if claim.is_none() {
        return HttpResponse::Forbidden().json(serde_json::json!({
            "status":"FAILED",
            "message":"Not authorized to update the issue."
        }));
    }

    let Ok(user) = User::get_user(&state.client, &claim.unwrap().sub).await else {
        return HttpResponse::InternalServerError().json(serde_json::json!({
            "message": "Unable to retrieve user",
        }));
    };

    HttpResponse::Ok().json(Into::<UserModel>::into(user))
}

We need one last endpoint which can fetch the user details by their user_id. This endpoint will be used to fetch issue author metadata from Clerk:

#[get("/user/{user_id}")]
async fn get_user_by_id(state: web::Data<AppState>, path: web::Path<String>) -> impl Responder {
    let user_id = path.into_inner();
    let Ok(user) = User::get_user(&state.client, &user_id).await else {
        return HttpResponse::InternalServerError().json(serde_json::json!({
            "status": "FAILED",
            "message": "Unable to retrieve all users",
        }));
    };

    HttpResponse::Ok().json(Into::<UserModel>::into(user))
}

Now, let’s add all these endpoints to our main function under the /api scope which is wrapped by Clerk middleware for protecting our endpoint routes and only allowing authenticated users to access these routes. After adding all the endpoints the main function you should have something like this.

Building the frontend and deploying

One important thing to notice is that this piece of code

.service(actix_files::Files::new("/", "./frontend/dist").index_file("index.html"))

is going the serve the static pages of the frontend application located in the ./frontend/dist directory whenever we build our Vite application, and this final output is optimized for production.

Follow along to part 2 where we build the frontend and deploy.

This blog post is powered by shuttle - The Rust-native, open source, cloud development platform. If you have any questions, or want to provide feedback, join our Discord server!
Share article
rocket

Build the Future of Backend Development with us

Join the movement and help revolutionize the world of backend development. Together, we can create the future!