Rust Web Server with Axum 0.8: Refresh Tokens and HttpOnly Cookies
November 3, 2026
Episode 20 of the Rust Backend series, where we build the backend of a blog step by step. After episode 19 every route that changes data needs a token. That token lives one hour. If it is stolen, the thief has one hour and we cannot stop it. If we make it short, users must log in all the time. Today we get both: short access tokens, and refresh tokens. 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 19. New crates today: rand, sha2, hex, time and axum-extra.
1. Two tokens
- The access token is short (in a real app about 15 minutes) and goes with every request. It is our JWT.
- The refresh token lives long (30 days), is stored in our database, and is used only to get a new access token. We can delete it at any time: that is a real logout.
2. Short tokens for the demo
For the demo, access tokens live 30 seconds:
let claims = Claims { sub: user_id, role: role.to_string(), exp: now + 30 };jsonwebtoken accepts a token for 60 seconds after exp by default (the leeway, for clocks that are a bit off). We set it to 0 so we don't have to wait 90 seconds:
pub fn read_token(token: &str, secret: &str) -> Result<Claims, ApiError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0;
let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &validation)
.map_err(|err| {
tracing::info!(?err, "bad token");
ApiError::Unauthorized
})?;
Ok(data.claims)
}Log in, call /me, wait 30 seconds, and call it again: 401, and the log says ExpiredSignature.
3. The refresh_tokens table
sqlx migrate add create_refresh_tokensCREATE TABLE refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);sqlx migrate runWe store a hash of the token, like with passwords: a stolen copy of the database cannot be used to log in.
4. Random tokens and a fast hash
cargo add rand sha2 hex timeA refresh token is not a JWT. It is just 32 random bytes, written as hex. A fast SHA-256 hash is enough here (no argon2), because the token is random, not a word a human chose: there is nothing to guess.
use sha2::{Digest, Sha256};
pub const REFRESH_SECS: i64 = 30 * 24 * 3600;
pub fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}
pub fn new_refresh_token() -> String {
let mut bytes = [0u8; 32];
rand::fill(&mut bytes);
hex::encode(bytes)
}
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}5. The cookie
Where does the browser keep the refresh token? In a cookie with four settings:
HttpOnly: JavaScript cannot read it, so an XSS bug cannot steal itSecure: it only travels over HTTPSSameSite=Strict: other sites cannot make the browser send itPath=/auth: it is only sent to our/auth/...routes, not with every request
cargo add axum-extra --features cookieuse axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
fn refresh_cookie(token: String) -> Cookie<'static> {
Cookie::build(("refresh", token))
.http_only(true)
.secure(true)
.same_site(SameSite::Strict)
.path("/auth")
.max_age(time::Duration::days(30))
.build()
}6. Login sets the cookie
axum-extra's CookieJar is an extractor (it reads the request's cookies) and a response part (returning it sets cookies). login now returns the jar with the new cookie, and the access token in JSON:
pub async fn login<R: Repo>(
State(repo): State<R>,
jar: CookieJar,
Json(body): Json<Login>,
) -> Result<(CookieJar, 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 access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
let refresh = auth::new_refresh_token();
repo.store_refresh(&auth::hash_token(&refresh), user.id, auth::now() + auth::REFRESH_SECS).await?;
Ok((jar.add(refresh_cookie(refresh)), Json(json!({ "token": access }))))
}7. refresh, with rotation
The refresh handler reads the cookie, hashes it, and finds it in the table. Then it rotates: the old token is deleted, a new one is stored, and a new access token is sent.
pub async fn refresh<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, Json<Value>), ApiError> {
let old = jar.get("refresh").ok_or(ApiError::Unauthorized)?.value().to_string();
let user_id = repo.take_refresh(&auth::hash_token(&old), auth::now()).await?.ok_or(ApiError::Unauthorized)?;
let user = repo.get_user(user_id).await?.ok_or(ApiError::Unauthorized)?;
let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
let new = auth::new_refresh_token();
repo.store_refresh(&auth::hash_token(&new), user.id, auth::now() + auth::REFRESH_SECS).await?;
Ok((jar.add(refresh_cookie(new)), Json(json!({ "token": access }))))
}take_refresh finds and deletes in one query, DELETE ... RETURNING, and only if it has not expired:
async fn take_refresh(&self, hash: &str, now: i64) -> sqlx::Result<Option<i64>> {
sqlx::query_scalar!(
r#"DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id as "user_id!""#,
hash,
now
)
.fetch_optional(&self.pool)
.await
}Rotation means a stolen refresh token works at most once: whoever uses it first makes the other copy useless.
8. A real logout
logout deletes the token from the table and removes the cookie:
pub async fn logout<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, StatusCode), ApiError> {
if let Some(cookie) = jar.get("refresh") {
repo.delete_refresh(&auth::hash_token(cookie.value())).await?;
}
Ok((jar.remove(Cookie::build("refresh").path("/auth")), StatusCode::NO_CONTENT))
}.route("/auth/refresh", post(handlers::refresh::<R>))
.route("/auth/logout", post(handlers::logout::<R>))store_refresh and delete_refresh are a plain INSERT and DELETE (see the final code below).
9. Try it
Run, and log in. This time curl saves the cookies in a file with -c:
cargo run
TOKEN=$(curl -s -c jar.txt localhost:8080/login -H 'content-type: application/json' -d '{"email":"ana@example.com","password":"hunter2hunter2"}' | jq -r .token)
cat jar.txtThe cookie file has the refresh token, marked HttpOnly, with path /auth. Keep a copy for later: cp jar.txt old_jar.txt.
Wait until the access token expires: /me answers 401. Now call /auth/refresh, sending the cookie with -b and saving the new one with -c:
TOKEN=$(curl -s -b jar.txt -c jar.txt -X POST localhost:8080/auth/refresh | jq -r .token)
curl -s localhost:8080/me -H "authorization: Bearer $TOKEN"; echoA new access token, a new cookie, and /me answers 200 again.
And the old refresh token, from before?
curl -i -b old_jar.txt -X POST localhost:8080/auth/refreshHTTP/1.1 401 UnauthorizedIt was rotated away. Finally, log out and try to refresh:
curl -si -b jar.txt -X POST localhost:8080/auth/logout | head -1
curl -si -b jar.txt -X POST localhost:8080/auth/refresh | head -1HTTP/1.1 204 No Content
HTTP/1.1 401 UnauthorizedLogged out for real.
The final code
migrations/0009_create_refresh_tokens.sql
-- Add migration script here
CREATE TABLE refresh_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);src/auth.rs
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 sha2::{Digest, Sha256};
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 + 30 };
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 mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0;
let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &validation)
.map_err(|err| {
tracing::info!(?err, "bad token");
ApiError::Unauthorized
})?;
Ok(data.claims)
}
pub const REFRESH_SECS: i64 = 30 * 24 * 3600;
pub fn now() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
}
pub fn new_refresh_token() -> String {
let mut bytes = [0u8; 32];
rand::fill(&mut bytes);
hex::encode(bytes)
}
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}src/handlers.rs
use axum::extract::{rejection::QueryRejection, Path, Query, State};
use axum::{http::StatusCode, Json};
use crate::error::ApiError;
use crate::extract::{AuthUser, 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};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
#[tracing::instrument(skip_all)]
pub async fn create_post<R: Repo>(
State(repo): State<R>,
user: AuthUser,
ValidJson(new): ValidJson<NewPost>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
let post = repo.create(user.id, 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>,
_user: AuthUser,
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>, _user: AuthUser) -> 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>,
_user: AuthUser,
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,
}
fn refresh_cookie(token: String) -> Cookie<'static> {
Cookie::build(("refresh", token))
.http_only(true)
.secure(true)
.same_site(SameSite::Strict)
.path("/auth")
.max_age(time::Duration::days(30))
.build()
}
pub async fn login<R: Repo>(
State(repo): State<R>,
jar: CookieJar,
Json(body): Json<Login>,
) -> Result<(CookieJar, 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 access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
let refresh = auth::new_refresh_token();
repo.store_refresh(&auth::hash_token(&refresh), user.id, auth::now() + auth::REFRESH_SECS).await?;
Ok((jar.add(refresh_cookie(refresh)), Json(json!({ "token": access }))))
}
pub async fn me<R: Repo>(State(repo): State<R>, user: AuthUser) -> Result<Json<User>, ApiError> {
let user = repo.get_user(user.id).await?.ok_or(ApiError::Unauthorized)?;
Ok(Json(user))
}
pub async fn refresh<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, Json<Value>), ApiError> {
let old = jar.get("refresh").ok_or(ApiError::Unauthorized)?.value().to_string();
let user_id = repo.take_refresh(&auth::hash_token(&old), auth::now()).await?.ok_or(ApiError::Unauthorized)?;
let user = repo.get_user(user_id).await?.ok_or(ApiError::Unauthorized)?;
let access = auth::make_token(user.id, &user.role, &config().jwt_secret)?;
let new = auth::new_refresh_token();
repo.store_refresh(&auth::hash_token(&new), user.id, auth::now() + auth::REFRESH_SECS).await?;
Ok((jar.add(refresh_cookie(new)), Json(json!({ "token": access }))))
}
pub async fn logout<R: Repo>(State(repo): State<R>, jar: CookieJar) -> Result<(CookieJar, StatusCode), ApiError> {
if let Some(cookie) = jar.get("refresh") {
repo.delete_refresh(&auth::hash_token(cookie.value())).await?;
}
Ok((jar.remove(Cookie::build("refresh").path("/auth")), StatusCode::NO_CONTENT))
}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>))
.route("/login", post(handlers::login::<R>))
.route("/me", get(handlers::me::<R>))
.route("/auth/refresh", post(handlers::refresh::<R>))
.route("/auth/logout", post(handlers::logout::<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/repo.rs
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, author_id: i64, 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;
fn store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> impl Future<Output = sqlx::Result<()>> + Send;
fn take_refresh(&self, hash: &str, now: i64) -> impl Future<Output = sqlx::Result<Option<i64>>> + Send;
fn delete_refresh(&self, hash: &str) -> impl Future<Output = sqlx::Result<()>> + 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, p.author_id 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, author_id FROM posts WHERE id = ? AND deleted_at IS NULL", id)
.fetch_optional(&self.pool)
.await
}
async fn create(&self, author_id: i64, 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, author_id) VALUES (?, ?, ?, ?) RETURNING id, title, body, slug, created_at, author_id"#,
new.title,
new.body,
slug,
author_id
)
.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, author_id"#,
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 store_refresh(&self, hash: &str, user_id: i64, expires_at: i64) -> sqlx::Result<()> {
sqlx::query!(
"INSERT INTO refresh_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
hash,
user_id,
expires_at
)
.execute(&self.pool)
.await?;
Ok(())
}
async fn take_refresh(&self, hash: &str, now: i64) -> sqlx::Result<Option<i64>> {
sqlx::query_scalar!(
r#"DELETE FROM refresh_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id as "user_id!""#,
hash,
now
)
.fetch_optional(&self.pool)
.await
}
async fn delete_refresh(&self, hash: &str) -> sqlx::Result<()> {
sqlx::query!("DELETE FROM refresh_tokens WHERE token_hash = ?", hash).execute(&self.pool).await?;
Ok(())
}
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
- Short access tokens (JWT) plus long refresh tokens (random, stored in the database)
- Store only a SHA-256 hash of each refresh token
- The cookie is
HttpOnly,Secure,SameSite=Strict,Path=/auth;CookieJarreads and sets it - Rotation: every refresh deletes the old token, so a stolen one works at most once
- Logout deletes the token from the database: a real logout, not just "forget the token"
Next episode: roles. Admins can do more, and everyone else gets 403 Forbidden. Watch the series on YouTube.