M Logo
๐Ÿ“ Rust Backend with Axum ยท Part 18 of 42

Rust Web Server with Axum 0.8: Login with JSON Web Tokens and 401

November 3, 2026

Episode 18 of the Rust Backend series, where we build the backend of a blog step by step. After episode 17 Ana can sign up, and her password is stored as an argon2 hash. Now she wants to log in, and then prove on every request that it is really her. Today: login, and JSON Web Tokens. All the code below is the final, tested code from the video, ready to copy.

We continue in the same blog_api project from episode 17. One new crate today: jsonwebtoken.

1. How a JWT works

After login, the server gives Ana a token. A JWT has three parts, split by dots:

text
header.claims.signature

The header names the algorithm, the claims say who she is (her user id) and when the token expires, and the signature is made with a secret that only our server knows. When she sends the token back, we check the signature: if anyone changed a single character, it no longer matches.

2. The secret

The secret goes into .env. Make a long random one:

bash
openssl rand -hex 32
text
JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47

(That is the demo value from the video. Use your own, and never commit it.) Config reads it, and the server refuses to start without it:

rust
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
Config { host, port, database_url, jwt_secret }

3. Claims and make_token

bash
cargo add jsonwebtoken

In auth.rs, the claims: sub (the subject, our user id), role, and exp, the expiry time in seconds since 1970:

rust
use jsonwebtoken::{encode, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Serialize, Deserialize)]
pub struct Claims {
    pub sub: i64,
    pub role: String,
    pub exp: u64,
}

pub fn make_token(user_id: i64, role: &str, secret: &str) -> Result<String, ApiError> {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    let claims = Claims { sub: user_id, role: role.to_string(), exp: now + 3600 };
    Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?)
}

The token lives one hour. Header::default() means HS256: an HMAC with SHA-256 and our secret. A Jwt variant in ApiError (#[from] jsonwebtoken::errors::Error, a 500 with the details only in the log) lets ? work.

4. Pick a crypto backend

To try it, main prints one token at start (println!("{:?}", auth::make_token(1, "user", &cfg.jwt_secret));):

bash
cargo run

An error: jsonwebtoken needs a crypto backend, and we did not choose one. Since version 11 the crate makes us pick between aws_lc_rs and rust_crypto. We pick aws_lc_rs:

bash
cargo add jsonwebtoken --features aws_lc_rs

Now it runs and prints a token. Remove the println! again.

5. Find the user and verify the password

Login needs the user with the hash, so a separate type (we never serialize it):

rust
pub struct UserWithHash {
    pub id: i64,
    pub email: String,
    pub password_hash: String,
    pub role: String,
}
rust
async fn find_user_by_email(&self, email: &str) -> sqlx::Result<Option<UserWithHash>> {
    sqlx::query_as!(
        UserWithHash,
        "SELECT id, email, password_hash, role FROM users WHERE email = ?",
        email
    )
    .fetch_optional(&self.pool)
    .await
}

verify_password parses the stored hash string (it contains the salt and settings) and checks the password, again in spawn_blocking, because argon2 is slow on purpose:

rust
use argon2::password_hash::{phc::PasswordHash, PasswordVerifier};

pub async fn verify_password(password: String, hash: String) -> Result<bool, ApiError> {
    let ok = tokio::task::spawn_blocking(move || {
        let parsed = PasswordHash::new(&hash)?;
        Ok::<_, argon2::password_hash::Error>(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
    })
    .await
    .expect("verify task panicked")?;
    Ok(ok)
}

6. The login handler and one 401

rust
#[derive(Deserialize)]
pub struct Login {
    pub email: String,
    pub password: String,
}

pub async fn login<R: Repo>(State(repo): State<R>, Json(body): Json<Login>) -> Result<Json<Value>, ApiError> {
    let user = repo.find_user_by_email(&body.email).await?.ok_or(ApiError::Unauthorized)?;
    if !auth::verify_password(body.password, user.password_hash).await? {
        return Err(ApiError::Unauthorized);
    }
    let token = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    Ok(Json(json!({ "token": token })))
}

A new variant, 401 Unauthorized:

rust
#[error("invalid email or password")]
Unauthorized,

An unknown email and a wrong password give the same answer. We never tell an attacker which part was wrong, or they could find out which emails have accounts.

rust
.route("/login", post(handlers::login::<R>))

7. Log in

bash
cargo run
TOKEN=$(curl -s localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}' | jq -r .token)
echo $TOKEN

200 OK and a token: three parts, split by dots. The middle part is just base64. Anyone can read it:

bash
echo $TOKEN | cut -d. -f2 | base64 -d

It shows sub, role and exp in plain JSON. A JWT is signed, not encrypted: never put secrets in it.

A wrong password:

bash
curl -i localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"wrong-password"}'
text
HTTP/1.1 401 Unauthorized

{"error":"invalid email or password"}

8. /me: use the token

A route that uses the token. read_token decodes and checks it; any problem (bad signature, expired, garbage) becomes a 401, and only the log gets the reason:

rust
pub fn read_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
    let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))
        .map_err(|err| {
            tracing::info!(?err, "bad token");
            ApiError::Unauthorized
        })?;
    Ok(data.claims)
}

The me handler reads the Authorization header, removes Bearer , decodes the token, and loads the user:

rust
pub async fn me<R: Repo>(State(repo): State<R>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
    let token = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(ApiError::Unauthorized)?;
    let claims = auth::read_token(token, &config().jwt_secret)?;
    let user = repo.get_user(claims.sub).await?.ok_or(ApiError::Unauthorized)?;
    Ok(Json(user))
}
rust
async fn get_user(&self, id: i64) -> sqlx::Result<Option<User>> {
    sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users WHERE id = ?", id)
        .fetch_optional(&self.pool)
        .await
}
rust
.route("/me", get(handlers::me::<R>))
bash
cargo run
curl -s localhost:8080/me -H "authorization: Bearer $TOKEN"; echo

200 OK, and Ana's user: id, email, role and created_at, no hash.

9. Change one letter

Now we change one character near the end of the token (the signature):

bash
curl -s -i localhost:8080/me -H "authorization: Bearer $(echo $TOKEN | sed 's/.\(.\)$/Z\1/')"
text
HTTP/1.1 401 Unauthorized

The log says InvalidSignature. Nobody can change a token, for example to "role":"admin", without the secret.

/me works, but those header lines would be copied into every protected handler. Next time we turn them into an extractor.

The final code

.env

text
HOST=0.0.0.0
PORT=8080
RUST_LOG=blog_api=debug,tower_http=debug
DATABASE_URL=sqlite://blog.db
JWT_SECRET=5c0e8f1b3a9d47e2b6f0a4c8d1e7239f6b5a0c3d8e1f47a29b6c5d0e3f8a1b47

src/auth.rs

rust
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::{password_hash::PasswordHasher, Argon2};
use argon2::password_hash::{phc::PasswordHash, PasswordVerifier};

use crate::error::ApiError;

pub async fn hash_password(password: String) -> Result<String, ApiError> {
    let hash = tokio::task::spawn_blocking(move || {
        Argon2::default().hash_password(password.as_bytes()).map(|h| h.to_string())
    })
    .await
    .expect("hash task panicked")?;
    Ok(hash)
}

#[derive(Serialize, Deserialize)]
pub struct Claims {
    pub sub: i64,
    pub role: String,
    pub exp: u64,
}

pub fn make_token(user_id: i64, role: &str, secret: &str) -> Result<String, ApiError> {
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
    let claims = Claims { sub: user_id, role: role.to_string(), exp: now + 3600 };
    Ok(encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))?)
}

pub async fn verify_password(password: String, hash: String) -> Result<bool, ApiError> {
    let ok = tokio::task::spawn_blocking(move || {
        let parsed = PasswordHash::new(&hash)?;
        Ok::<_, argon2::password_hash::Error>(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
    })
    .await
    .expect("verify task panicked")?;
    Ok(ok)
}

pub fn read_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
    let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::new(Algorithm::HS256))
        .map_err(|err| {
            tracing::info!(?err, "bad token");
            ApiError::Unauthorized
        })?;
    Ok(data.claims)
}

src/config.rs

rust
use std::sync::OnceLock;

pub struct Config {
    pub host: String,
    pub port: u16,
    pub database_url: String,
    pub jwt_secret: String,
}

impl Config {
    fn from_env() -> Config {
        dotenvy::dotenv().ok();
        let host = std::env::var("HOST").unwrap_or("0.0.0.0".to_string());
        let port = std::env::var("PORT").unwrap_or("3000".to_string());
        let port = port
            .parse()
            .unwrap_or_else(|e| panic!("PORT {port:?} is not a port number: {e}"));
        let database_url = std::env::var("DATABASE_URL").unwrap_or("sqlite://blog.db".to_string());
        let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
        Config { host, port, database_url, jwt_secret }
    }
}

static CONFIG: OnceLock<Config> = OnceLock::new();

pub fn config() -> &'static Config {
    CONFIG.get_or_init(Config::from_env)
}

src/error.rs

rust
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
use std::collections::BTreeMap;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("post {0} not found")]
    NotFound(i64),
    #[error(transparent)]
    BadJson(#[from] JsonRejection),
    #[error(transparent)]
    BadQuery(#[from] QueryRejection),
    #[error("database error")]
    Database(#[from] sqlx::Error),
    #[error("{0}")]
    Conflict(String),
    #[error("validation failed")]
    Validation(#[from] validator::ValidationErrors),
    #[error("internal error")]
    Hash(#[from] argon2::password_hash::Error),
    #[error("internal error")]
    Jwt(#[from] jsonwebtoken::errors::Error),
    #[error("invalid email or password")]
    Unauthorized,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        tracing::warn!(error = %self, "request failed");
        let status = match &self {
            ApiError::NotFound(_) => StatusCode::NOT_FOUND,
            ApiError::Conflict(_) => StatusCode::CONFLICT,
            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
            ApiError::BadJson(rejection) => rejection.status(),
            ApiError::BadQuery(rejection) => rejection.status(),
            ApiError::Validation(errors) => {
                let fields: BTreeMap<_, Vec<String>> = errors
                    .field_errors()
                    .iter()
                    .map(|(field, errs)| {
                        let msgs = errs.iter().map(|e| e.message.clone().unwrap_or(e.code.clone()).to_string()).collect();
                        (field.to_string(), msgs)
                    })
                    .collect();
                return (StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": "validation failed", "fields": fields })))
                    .into_response();
            }
            ApiError::Hash(err) => {
                tracing::error!(?err, "password hashing failed");
                StatusCode::INTERNAL_SERVER_ERROR
            }
            ApiError::Jwt(err) => {
                tracing::error!(?err, "token error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
            ApiError::Database(err) => {
                tracing::error!(?err, "database error");
                StatusCode::INTERNAL_SERVER_ERROR
            }
        };
        let message = match &self {
            ApiError::BadJson(rejection) => rejection.body_text(),
            ApiError::BadQuery(rejection) => rejection.body_text(),
            other => other.to_string(),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

src/handlers.rs

rust
use axum::extract::{rejection::QueryRejection, Path, Query, State};
use axum::{http::{header, HeaderMap, StatusCode}, Json};

use crate::error::ApiError;
use crate::extract::ValidJson;
use crate::models::{Comment, Hit, ListFilter, ListParams, NewComment, NewPost, NewUser, Page, Post, PostWithTags, SearchParams, User};
use crate::repo::Repo;
use crate::auth;
use crate::config::config;
use serde::Deserialize;
use serde_json::{json, Value};

#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(
    State(repo): State<R>,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
    let post = repo.create(new).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
        _ => ApiError::Database(err),
    })?;
    tracing::debug!(id = post.post.id, "created");
    Ok((StatusCode::CREATED, Json(post)))
}

pub async fn get_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
    let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn list_posts<R: Repo>(
    State(repo): State<R>,
    params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
    let Query(p) = params?;
    let page = p.page.unwrap_or(1).max(1);
    let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
    let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
    Ok(Json(Page { items, page, per_page, total }))
}

pub async fn update_post<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    ValidJson(new): ValidJson<NewPost>,
) -> Result<Json<Post>, ApiError> {
    let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(post))
}

pub async fn delete_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.delete(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::NO_CONTENT)
}

pub async fn list_comments<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
) -> Result<Json<Vec<Comment>>, ApiError> {
    repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
    Ok(Json(repo.list_comments(id).await?))
}

pub async fn add_comment<R: Repo>(
    State(repo): State<R>,
    Path(id): Path<i64>,
    ValidJson(new): ValidJson<NewComment>,
) -> Result<(StatusCode, Json<Comment>), ApiError> {
    let comment = repo.add_comment(id, new).await.map_err(|err| {
        match err.as_database_error() {
            Some(db_err) if db_err.is_foreign_key_violation() => ApiError::NotFound(id),
            _ => ApiError::Database(err),
        }
    })?;
    Ok((StatusCode::CREATED, Json(comment)))
}

pub async fn search_posts<R: Repo>(
    State(repo): State<R>,
    Query(p): Query<SearchParams>,
) -> Result<Json<Vec<Hit>>, ApiError> {
    Ok(Json(repo.search(&p.q).await?))
}

pub async fn restore_post<R: Repo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
    if !repo.restore(id).await? {
        return Err(ApiError::NotFound(id));
    }
    Ok(StatusCode::OK)
}

pub async fn signup<R: Repo>(
    State(repo): State<R>,
    ValidJson(new): ValidJson<NewUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let hash = crate::auth::hash_password(new.password).await?;
    let user = repo.create_user(&new.email, &hash).await.map_err(|err| match err.as_database_error() {
        Some(e) if e.is_unique_violation() => ApiError::Conflict("email already registered".into()),
        _ => ApiError::Database(err),
    })?;
    Ok((StatusCode::CREATED, Json(user)))
}

#[derive(Deserialize)]
pub struct Login {
    pub email: String,
    pub password: String,
}

pub async fn login<R: Repo>(State(repo): State<R>, Json(body): Json<Login>) -> Result<Json<Value>, ApiError> {
    let user = repo.find_user_by_email(&body.email).await?.ok_or(ApiError::Unauthorized)?;
    if !auth::verify_password(body.password, user.password_hash).await? {
        return Err(ApiError::Unauthorized);
    }
    let token = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
    Ok(Json(json!({ "token": token })))
}

pub async fn me<R: Repo>(State(repo): State<R>, headers: HeaderMap) -> Result<Json<User>, ApiError> {
    let token = headers
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .ok_or(ApiError::Unauthorized)?;
    let claims = auth::read_token(token, &config().jwt_secret)?;
    let user = repo.get_user(claims.sub).await?.ok_or(ApiError::Unauthorized)?;
    Ok(Json(user))
}

src/main.rs

rust
mod auth;
mod config;
mod error;
mod extract;
mod handlers;
mod models;
mod repo;

use axum::{routing::{get, post}, Router};
use repo::{Repo, SqliteRepo};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;

pub fn app<R: Repo>(repo: R) -> Router {
    Router::new()
        .route("/posts", get(handlers::list_posts::<R>).post(handlers::create_post::<R>))
        .route(
            "/posts/{id}",
            get(handlers::get_post::<R>).put(handlers::update_post::<R>).delete(handlers::delete_post::<R>),
        )
        .route("/posts/{id}/comments", get(handlers::list_comments::<R>).post(handlers::add_comment::<R>))
        .route("/search", get(handlers::search_posts::<R>))
        .route("/posts/{id}/restore", post(handlers::restore_post::<R>))
        .route("/signup", post(handlers::signup::<R>))
        .route("/login", post(handlers::login::<R>))
        .route("/me", get(handlers::me::<R>))
        .with_state(repo)
        .layer(TraceLayer::new_for_http())
}

#[tokio::main]
async fn main() {
    let cfg = config::config();
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .init();
    let addr = format!("{}:{}", cfg.host, cfg.port);
    let options = SqliteConnectOptions::from_str(&cfg.database_url)
        .unwrap()
        .create_if_missing(true)
        .journal_mode(SqliteJournalMode::Wal);
    let pool = SqlitePoolOptions::new()
        .max_connections(5)
        .connect_with(options)
        .await
        .unwrap();
    sqlx::migrate!().run(&pool).await.unwrap();
    let app = app(SqliteRepo { pool });

    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    tracing::info!(%addr, "listening");
    axum::serve(listener, app).await.unwrap();
}

src/models.rs

rust
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};

#[derive(Serialize, Clone, sqlx::FromRow)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub slug: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewPost {
    #[validate(length(min = 1, max = 200, message = "1 to 200 characters"), custom(function = "not_blank"))]
    pub title: String,
    #[validate(length(min = 1, max = 20000, message = "must not be empty"))]
    pub body: String,
    #[serde(default)]
    #[validate(length(max = 5, message = "at most 5 tags"))]
    pub tags: Vec<String>,
}

#[derive(Serialize, Clone)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: String,
}

#[derive(Deserialize, Validate)]
pub struct NewComment {
    #[validate(length(min = 1, max = 2000, message = "1 to 2000 characters"), custom(function = "not_blank"))]
    pub body: String,
}

pub fn slugify(title: &str) -> String {
    let mut slug = String::new();
    for c in title.trim().to_lowercase().chars() {
        if c.is_alphanumeric() {
            slug.push(c);
        } else if !slug.ends_with('-') {
            slug.push('-');
        }
    }
    slug.trim_end_matches('-').to_string()
}

#[derive(Serialize)]
pub struct PostWithTags {
    #[serde(flatten)]
    pub post: Post,
    pub tags: Vec<String>,
}

#[derive(Deserialize)]
pub struct ListParams {
    pub page: Option<u32>,
    pub per_page: Option<u32>,
    pub tag: Option<String>,
    #[serde(default)]
    pub sort: Sort,
}

#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
    #[default]
    Newest,
    Oldest,
    Title,
}

#[derive(Serialize)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

pub struct ListFilter {
    pub page: u32,
    pub per_page: u32,
    pub tag: Option<String>,
    pub sort: Sort,
}

#[derive(Serialize)]
pub struct Hit {
    pub id: i64,
    pub title: String,
    pub snippet: String,
}

#[derive(Deserialize)]
pub struct SearchParams {
    pub q: String,
}

pub fn not_blank(value: &str) -> Result<(), ValidationError> {
    if value.trim().is_empty() {
        return Err(ValidationError::new("blank").with_message("must not be blank".into()));
    }
    Ok(())
}

#[derive(Deserialize, Validate)]
pub struct NewUser {
    #[validate(email(message = "must be a valid email"))]
    pub email: String,
    #[validate(length(min = 10, max = 200, message = "10 to 200 characters"))]
    pub password: String,
}

#[derive(Serialize, Clone)]
pub struct User {
    pub id: i64,
    pub email: String,
    pub role: String,
    pub created_at: String,
}

pub struct UserWithHash {
    pub id: i64,
    pub email: String,
    pub password_hash: String,
    pub role: String,
}

src/repo.rs

rust
use std::future::Future;

use sqlx::{QueryBuilder, Sqlite, SqlitePool};

use crate::models::{slugify, Comment, Hit, ListFilter, NewComment, NewPost, Post, PostWithTags, Sort, User, UserWithHash};

pub trait Repo: Clone + Send + Sync + 'static {
    fn list(&self, f: ListFilter) -> impl Future<Output = sqlx::Result<(Vec<Post>, i64)>> + Send;
    fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn create(&self, new: NewPost) -> impl Future<Output = sqlx::Result<PostWithTags>> + Send;
    fn update(&self, id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
    fn delete(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn restore(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
    fn list_comments(&self, post_id: i64) -> impl Future<Output = sqlx::Result<Vec<Comment>>> + Send;
    fn add_comment(&self, post_id: i64, new: NewComment) -> impl Future<Output = sqlx::Result<Comment>> + Send;
    fn search(&self, q: &str) -> impl Future<Output = sqlx::Result<Vec<Hit>>> + Send;
    fn create_user(&self, email: &str, password_hash: &str) -> impl Future<Output = sqlx::Result<User>> + Send;
    fn find_user_by_email(&self, email: &str) -> impl Future<Output = sqlx::Result<Option<UserWithHash>>> + Send;
    fn get_user(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<User>>> + Send;
}

#[derive(Clone)]
pub struct SqliteRepo {
    pub pool: SqlitePool,
}

impl Repo for SqliteRepo {
    async fn list(&self, f: ListFilter) -> sqlx::Result<(Vec<Post>, i64)> {
        let mut q = QueryBuilder::<Sqlite>::new("SELECT p.id, p.title, p.body, p.slug, p.created_at FROM posts p WHERE p.deleted_at IS NULL");
        if let Some(tag) = &f.tag {
            q.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        q.push(match f.sort {
            Sort::Newest => " ORDER BY p.id DESC",
            Sort::Oldest => " ORDER BY p.id ASC",
            Sort::Title => " ORDER BY p.title ASC",
        });
        q.push(" LIMIT ").push_bind(f.per_page as i64);
        q.push(" OFFSET ").push_bind(((f.page - 1) * f.per_page) as i64);
        let posts = q.build_query_as::<Post>().fetch_all(&self.pool).await?;

        let mut c = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM posts p WHERE p.deleted_at IS NULL");
        if let Some(tag) = &f.tag {
            c.push(" AND p.id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
                .push_bind(tag.clone())
                .push(")");
        }
        let total: i64 = c.build_query_scalar().fetch_one(&self.pool).await?;
        Ok((posts, total))
    }

    async fn search(&self, q: &str) -> sqlx::Result<Vec<Hit>> {
        let q = fts_query(q);
        sqlx::query_as!(
            Hit,
            r#"SELECT p.id as "id!", p.title, snippet(posts_fts, 1, '[', ']', '...', 8) as "snippet!: String"
               FROM posts_fts JOIN posts p ON p.id = posts_fts.rowid
               WHERE posts_fts MATCH ? AND p.deleted_at IS NULL
               ORDER BY bm25(posts_fts) LIMIT 20"#,
            q
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn get(&self, id: i64) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(Post, "SELECT id, title, body, slug, created_at FROM posts WHERE id = ? AND deleted_at IS NULL", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn create(&self, new: NewPost) -> sqlx::Result<PostWithTags> {
        let slug = slugify(&new.title);
        let mut tx = self.pool.begin().await?;
        let post = sqlx::query_as!(
            Post,
            r#"INSERT INTO posts (title, body, slug) VALUES (?, ?, ?) RETURNING id, title, body, slug, created_at"#,
            new.title,
            new.body,
            slug
        )
        .fetch_one(&mut *tx)
        .await?;
        for tag in &new.tags {
            sqlx::query!("INSERT INTO tags (name) VALUES (?) ON CONFLICT (name) DO NOTHING", tag)
                .execute(&mut *tx)
                .await?;
            sqlx::query!(
                "INSERT INTO post_tags (post_id, tag_id) SELECT ?, id FROM tags WHERE name = ?",
                post.id,
                tag
            )
            .execute(&mut *tx)
            .await?;
        }
        tx.commit().await?;
        Ok(PostWithTags { post, tags: new.tags })
    }

    async fn update(&self, id: i64, new: NewPost) -> sqlx::Result<Option<Post>> {
        sqlx::query_as!(
            Post,
            r#"UPDATE posts SET title = ?, body = ? WHERE id = ? AND deleted_at IS NULL RETURNING id, title, body, slug, created_at"#,
            new.title,
            new.body,
            id
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn delete(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn restore(&self, id: i64) -> sqlx::Result<bool> {
        let result = sqlx::query!("UPDATE posts SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL", id)
            .execute(&self.pool)
            .await?;
        Ok(result.rows_affected() > 0)
    }

    async fn create_user(&self, email: &str, password_hash: &str) -> sqlx::Result<User> {
        sqlx::query_as!(
            User,
            r#"INSERT INTO users (email, password_hash) VALUES (?, ?) RETURNING id, email, role, created_at"#,
            email,
            password_hash
        )
        .fetch_one(&self.pool)
        .await
    }

    async fn find_user_by_email(&self, email: &str) -> sqlx::Result<Option<UserWithHash>> {
        sqlx::query_as!(
            UserWithHash,
            "SELECT id, email, password_hash, role FROM users WHERE email = ?",
            email
        )
        .fetch_optional(&self.pool)
        .await
    }

    async fn get_user(&self, id: i64) -> sqlx::Result<Option<User>> {
        sqlx::query_as!(User, "SELECT id, email, role, created_at FROM users WHERE id = ?", id)
            .fetch_optional(&self.pool)
            .await
    }

    async fn list_comments(&self, post_id: i64) -> sqlx::Result<Vec<Comment>> {
        sqlx::query_as!(
            Comment,
            "SELECT id, post_id, body, created_at FROM comments WHERE post_id = ? ORDER BY id",
            post_id
        )
        .fetch_all(&self.pool)
        .await
    }

    async fn add_comment(&self, post_id: i64, new: NewComment) -> sqlx::Result<Comment> {
        sqlx::query_as!(
            Comment,
            r#"INSERT INTO comments (post_id, body) VALUES (?, ?) RETURNING id as "id!", post_id, body, created_at"#,
            post_id,
            new.body
        )
        .fetch_one(&self.pool)
        .await
    }
}

fn fts_query(input: &str) -> String {
    input
        .split_whitespace()
        .map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
        .collect::<Vec<_>>()
        .join(" ")
}

Recap

  • A JWT is header.claims.signature: sub, role and exp, signed with a secret from .env
  • jsonwebtoken 11 needs a crypto backend feature: aws_lc_rs or rust_crypto
  • verify_password checks against the argon2 hash, in spawn_blocking
  • Unknown email and wrong password give the same 401
  • Signed is not encrypted: anyone can read the claims, nobody can change them

Next episode: an auth extractor. Protected routes become one parameter. Watch the series on YouTube.

ยฉ 2026 All Rights Reserved By Mahmudul Alam