Rust Web Server with Axum 0.8: Sign-Up with argon2 Password Hashing
November 3, 2026
Episode 17 of the Rust Backend series, where we build the backend of a blog step by step. After episode 16 every request body is validated. But right now anybody can write and delete posts. Before we can protect them, we need users. Today: sign up, and storing passwords the right way. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 16. One new crate today: argon2. The passwords below are throw-away demo values.
1. The users table
sqlx migrate add create_usersCREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);COLLATE NOCASE makes the unique check ignore case: Ana@Example.com and ana@example.com are the same user. The role column is for episode 21.
sqlx migrate run2. PostRepo becomes Repo
Our repository now holds more than posts, so PostRepo becomes Repo and SqlitePostRepo becomes SqliteRepo. sed renames them in every file:
sed -i 's/PostRepo/Repo/g' src/*.rs3. NewUser and User
NewUser is what the client sends, with validation from episode 16: a real email, and at least 10 characters. User is what we send back, and it has no password field, ever:
#[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,
}In the Repo trait, fn create_user(&self, email: &str, password_hash: &str) -> impl Future<Output = sqlx::Result<User>> + Send; and its implementation:
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
}4. First the wrong way
To see why it matters, the first version of the handler stores the password as it is. A taken email becomes a 409, like the duplicate slug in episode 13:
pub async fn signup<R: Repo>(
State(repo): State<R>,
ValidJson(new): ValidJson<NewUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
let user = repo.create_user(&new.email, &new.password).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)))
}.route("/signup", post(handlers::signup::<R>))cargo run
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}'HTTP/1.1 201 Created
content-type: application/json
{"id":1,"email":"ana@example.com","role":"user","created_at":"2026-10-30 02:25:29"}The answer looks fine. Now look into the database:
sqlite3 blog.db 'select email, password_hash from users;'ana@example.com|hunter2hunter2There it is, in plain text. Anyone who gets a copy of this file (a backup, a log, a stolen laptop) can log in as Ana. And people reuse passwords, so it may open her email too.
5. Hashing, with argon2
A hash is a one-way function: easy to compute, impossible to reverse. We store the hash, and at login (next episode) we hash the typed password again and compare.
For passwords the hash must be slow and use a random salt, so that guessing millions of passwords per second is not possible and two users with the same password get different hashes. argon2 is the recommended one.
cargo add argon2New file src/auth.rs:
use argon2::{password_hash::PasswordHasher, Argon2};
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
Argon2::default().hash_password(password.as_bytes()).map(|h| h.to_string())
}Argon2::default() uses the recommended settings, and hash_password makes the random salt for us. to_string() gives one string with everything inside: the algorithm, the settings, the salt and the hash.
6. ? cannot convert the error
The handler calls it with ? and returns ApiError. cargo build:
cargo builderror[E0277]: `?` couldn't convert the error to `ApiError`The same lesson as in episode 5: ? needs a From. A new variant, with #[from]:
#[error("internal error")]
Hash(#[from] argon2::password_hash::Error),It becomes a 500, and only the log gets the details; the client never learns them:
ApiError::Hash(err) => {
tracing::error!(?err, "password hashing failed");
StatusCode::INTERNAL_SERVER_ERROR
}7. spawn_blocking for slow work
One more thing. argon2 is slow on purpose, tens of milliseconds of pure CPU work. Inside an async fn that would block one of Tokio's few worker threads, and every other request on it would wait. tokio::task::spawn_blocking moves the work to a thread pool made for slow, blocking jobs:
use argon2::{password_hash::PasswordHasher, Argon2};
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)
}The closure must own its data (move, and a String instead of &str), because it runs on another thread. .await gives a Result for the task itself (it fails only if the closure panicked), and ? handles the hash error. In the handler:
let hash = crate::auth::hash_password(new.password).await?;
let user = repo.create_user(&new.email, &hash).await.map_err(/* as before */)?;Add mod auth; to main.rs.
8. Try it again
Delete Ana, run, and sign up again:
sqlite3 blog.db 'delete from users;'
cargo run
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}'
curl -s localhost:8080/signup -H 'content-type: application/json' -d '{"email":"ben@example.com","password":"hunter2hunter2"}'
sqlite3 blog.db 'select email, password_hash from users;'ana@example.com|$argon2id$v=19$m=19456,t=2,p=1$Xv26m13s0hXr9z0o8UMznQ$rwsd1SRP0mUIcELHFbsCWsOoLBANKE/BUKwmT4M6rv8
ben@example.com|$argon2id$v=19$m=19456,t=2,p=1$ETAxP+Ut4FrLKcM/yvMCrA$hK0Bt/jWoIIkVXkyZ0m6R1MRIhCnVp1WJBhoMFBYx/MNo password anywhere: argon2id, the settings (m memory, t passes, p threads), then the salt, then the hash. Ben has the same password as Ana, but a completely different hash, because his salt is different.
Ana signs up a second time:
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}'HTTP/1.1 409 Conflict
{"error":"email already registered"}And a bad email with a short password gets a 422 with both fields:
curl -i localhost:8080/signup -H 'content-type: application/json' -d '{"email":"ana","password":"123"}'{"error":"validation failed","fields":{"email":["must be a valid email"],"password":["10 to 200 characters"]}}The final code
migrations/0007_create_users.sql
-- Add migration script here
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);src/auth.rs
use argon2::{password_hash::PasswordHasher, Argon2};
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)
}src/error.rs
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),
}
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::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::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
use axum::extract::{rejection::QueryRejection, Path, Query, State};
use axum::{http::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;
#[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)))
}src/main.rs
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>))
.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
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,
}src/repo.rs
use std::future::Future;
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use crate::models::{slugify, Comment, Hit, ListFilter, NewComment, NewPost, Post, PostWithTags, Sort, User};
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;
}
#[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 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
- Never store a plain password: a copy of the database would give away every account
- argon2 is slow on purpose and salts every hash; the result string holds the settings, salt and hash
#[from] argon2::password_hash::Errorlets?work, and the client only sees a 500spawn_blockingkeeps slow CPU work off Tokio's worker threadsUserhas no password field, so it can never leak in a response
Next episode: login. We check the password and hand out a JSON Web Token, and wrong passwords get 401. Watch the series on YouTube.