Add Authentication
What You'll Build
A complete auth system with user registration, login, JWT access tokens, refresh token rotation, and protected routes. The backend code is identical for both frontends; only the client wiring differs.
Prerequisites
- A Forge project (from the previous tutorial or
forge new) - The
bcryptcrate for password hashing (cargo add bcrypt)
Step 1: Configure Auth
Add an [auth] section to your forge.toml:
[auth]
jwt_algorithm = "HS256"
jwt_secret = "${JWT_SECRET}"
access_token_ttl = "1h"
refresh_token_ttl = "30d"
Then create a .env file in your project root:
JWT_SECRET=replace-me-with-a-long-random-string
Forge reads ${JWT_SECRET} from the environment at startup. Never commit your .env file to version control.
Step 2: Define Auth Types
Create a User model for the database and a UserPublic model for API responses. Keep password_hash out of the public type entirely -- #[serde(skip)] is not enough since code generation still sees the field.
use forge::prelude::*;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: String,
pub role: UserRole,
pub created_at: Timestamp,
pub updated_at: Timestamp,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UserPublic {
pub id: Uuid,
pub email: String,
pub name: String,
pub role: UserRole,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
impl From<User> for UserPublic {
fn from(u: User) -> Self {
Self {
id: u.id, email: u.email, name: u.name,
role: u.role, created_at: u.created_at, updated_at: u.updated_at,
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub user: UserPublic,
}
Define the input types in your auth module:
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct RegisterInput {
pub email: String,
pub name: String,
pub password: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct LoginInput {
pub email: String,
pub password: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct RefreshInput {
pub refresh_token: String,
}
Step 3: Create the Register Mutation
The register mutation validates input, hashes the password with bcrypt, inserts the user, and issues a JWT + refresh token pair. Mark it public so unauthenticated users can call it.
use crate::schema::{AuthResponse, User, UserPublic, UserRole};
use forge::prelude::*;
/// Helper that issues a token pair and builds the response.
async fn auth_response(ctx: &MutationContext, user: &User) -> Result<AuthResponse> {
let pair = ctx.issue_token_pair(user.id, &["user"]).await?;
Ok(AuthResponse {
access_token: pair.access_token,
refresh_token: pair.refresh_token,
user: UserPublic::from(user.clone()),
})
}
#[forge::mutation(public)]
pub async fn register(ctx: &MutationContext, input: RegisterInput) -> Result<AuthResponse> {
let email = input.email.trim();
if email.is_empty() {
return Err(ForgeError::Validation("Email is required".into()));
}
if input.name.trim().is_empty() {
return Err(ForgeError::Validation("Name is required".into()));
}
if input.password.len() < 8 {
return Err(ForgeError::Validation(
"Password must be at least 8 characters".into(),
));
}
let password_hash =
bcrypt::hash(&input.password, 10).map_err(|e| ForgeError::Internal(e.to_string()))?;
let id = Uuid::new_v4();
let now = Utc::now();
let mut conn = ctx.conn().await?;
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, role, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, name, role as "role: UserRole",
password_hash, created_at, updated_at
"#,
id, email, &input.name.trim().to_string(),
UserRole::Member as UserRole, &password_hash, now, now
)
.fetch_one(&mut conn)
.await
.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.constraint() == Some("idx_users_email") => {
ForgeError::Validation("Email already registered".into())
}
_ => ForgeError::from(e),
})?;
auth_response(ctx, &user).await
}
ctx.issue_token_pair(user_id, roles) handles all the cryptography. It creates a signed JWT access token and a random opaque refresh token, storing the refresh token hash in the forge_refresh_tokens system table. Token lifetimes come from forge.toml.
Step 4: Create the Login Mutation
#[forge::mutation(public)]
pub async fn login(ctx: &MutationContext, input: LoginInput) -> Result<AuthResponse> {
let mut conn = ctx.conn().await?;
let user = sqlx::query_as!(
User,
r#"
SELECT id, email, name, role as "role: UserRole",
password_hash, created_at, updated_at
FROM users WHERE email = $1
"#,
&input.email
)
.fetch_optional(&mut conn)
.await?
.ok_or_else(|| ForgeError::Validation("Invalid email or password".into()))?;
let hash = user.password_hash.as_deref()
.ok_or_else(|| ForgeError::Validation("Invalid email or password".into()))?;
let valid = bcrypt::verify(&input.password, hash)
.map_err(|e| ForgeError::Internal(e.to_string()))?;
if !valid {
return Err(ForgeError::Validation("Invalid email or password".into()));
}
auth_response(ctx, &user).await
}
Notice the error messages are identical for "user not found" and "wrong password" -- this prevents email enumeration attacks.
Step 5: Create the Refresh Token Mutation
Refresh token rotation is a single method call. Forge atomically deletes the old token and issues a new pair:
#[forge::mutation(public)]
pub async fn refresh_token(
ctx: &MutationContext,
input: RefreshInput,
) -> Result<TokenPair> {
ctx.rotate_refresh_token(&input.refresh_token).await
}
If the refresh token is expired or already consumed, this returns an error and the client should redirect to login.
Step 6: Protect Your Routes
Forge functions are authenticated by default. Use attributes to change the access level:
// Requires a valid JWT (the default -- no attribute needed)
#[forge::query]
pub async fn my_profile(ctx: &QueryContext) -> Result<UserPublic> {
let user_id = ctx.require_user_id()?;
// ... fetch and return user
}
// Open to everyone, no token required
#[forge::query(public)]
pub async fn health_check(ctx: &QueryContext) -> Result<String> {
Ok("ok".into())
}
// Requires auth AND the "admin" role in the JWT claims
#[forge::mutation(require_role("admin"))]
pub async fn delete_user(ctx: &MutationContext, input: DeleteInput) -> Result<()> {
// ...
}
Inside any authenticated handler, use ctx.require_user_id() to get the caller's UUID. Never trust a user ID sent from the client.
Step 7: Wire Up the Frontend
Run forge generate to produce typed client bindings. Then wire up auth in your frontend:
- SvelteKit
- Dioxus
Forge generates an auth.svelte.ts store that handles localStorage persistence, token refresh, and SSE reconnection.
Root layout -- start the refresh loop:
<script lang="ts">
import { ForgeProvider } from "@forge-rs/svelte";
import { auth, getToken } from "$lib/forge/auth.svelte";
const API_URL = "http://localhost:9081";
auth.startRefreshLoop(API_URL);
</script>
<ForgeProvider url={API_URL} {getToken}>
{@render children()}
</ForgeProvider>
Login page:
<script lang="ts">
import { login } from "$lib/forge/api";
import { auth } from "$lib/forge/auth.svelte";
import { goto } from "$app/navigation";
let email = $state("");
let password = $state("");
let error = $state("");
async function handleLogin() {
try {
const res = await login({ email, password });
auth.setAuth(res.access_token, res.refresh_token, res.user);
goto("/");
} catch (e) {
error = e.message;
}
}
</script>
<form onsubmit={handleLogin}>
<input bind:value={email} type="email" placeholder="Email" />
<input bind:value={password} type="password" placeholder="Password" />
<button type="submit">Log in</button>
{#if error}<p class="error">{error}</p>{/if}
</form>
Protecting a page:
<script lang="ts">
import { auth } from "$lib/forge/auth.svelte";
import { goto } from "$app/navigation";
if (!auth.isAuthenticated) goto("/login");
</script>
<h1>Welcome, {auth.user?.name}</h1>
<button onclick={() => { auth.clearAuth(); goto("/login"); }}>
Log out
</button>
Use ForgeAuthProvider instead of ForgeProvider. It handles token storage, refresh loops, and 401 recovery automatically.
App root:
use forge_dioxus::prelude::*;
fn App() -> Element {
rsx! {
ForgeAuthProvider {
url: "http://localhost:9081",
app_name: "my-app",
refresh_interval_secs: 2400, // ~40 min, about 2/3 of access_token_ttl
Router::<Route> {}
}
}
}
Login page:
use forge_dioxus::prelude::*;
fn LoginPage() -> Element {
let mut auth = use_forge_auth();
let mut email = use_signal(String::new);
let mut password = use_signal(String::new);
let mut error = use_signal(|| None::<String>);
let handle_login = move |_| {
spawn(async move {
match login(LoginInput {
email: email(),
password: password(),
}).await {
Ok(res) => {
auth.login_with_viewer(
res.access_token,
res.refresh_token,
&res.user,
);
navigator().push(Route::Dashboard);
}
Err(e) => error.set(Some(e.message)),
}
});
};
rsx! {
form { onsubmit: handle_login,
input { value: "{email}", oninput: move |e| email.set(e.value()) }
input { r#type: "password", value: "{password}",
oninput: move |e| password.set(e.value()) }
button { "Log in" }
if let Some(msg) = error() { p { class: "error", "{msg}" } }
}
}
}
Reading the current user and protecting routes:
// Read the viewer anywhere (typed to your user struct)
let viewer: Option<UserPublic> = use_viewer::<UserPublic>();
// Route guard -- redirects to /login when unauthenticated
fn ProtectedPage() -> Element {
if !use_require_auth("/login") {
return rsx! {};
}
let viewer = use_viewer::<UserPublic>().unwrap();
rsx! { h1 { "Welcome, {viewer.name}" } }
}
// Logout
let mut auth = use_forge_auth();
auth.logout();
Step 8: Test It
Forge provides test context builders that let you simulate authenticated and unauthenticated requests:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_short_password() {
let input = RegisterInput {
email: "test@example.com".into(),
name: "Test".into(),
password: "short".into(),
};
// Validation runs before any DB call
assert!(input.password.len() < 8);
}
#[tokio::test]
async fn protected_query_rejects_anonymous() {
let ctx = TestQueryContext::minimal(); // no auth
let result = my_profile(&ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn protected_query_works_for_authenticated_user() {
let ctx = TestQueryContext::builder()
.as_user(Uuid::new_v4())
.with_role("user")
.build();
assert!(ctx.auth.is_authenticated());
}
}
Key Concepts
- Tokens are short-lived. Access tokens default to 1 hour; refresh tokens default to 30 days. Tune these with
access_token_ttlandrefresh_token_ttlinforge.toml. - Refresh rotation is automatic. The SvelteKit store runs a refresh loop every 40 minutes by default. The Dioxus
ForgeAuthProviderdoes the same viarefresh_interval_secs. Set the interval to roughly 2/3 of youraccess_token_ttl. - SSE reconnects on auth changes. Both frontends reconnect the real-time event stream when tokens change, so subscriptions pick up the new identity immediately.
- Never trust client-supplied user IDs. Always call
ctx.require_user_id()to get the authenticated user's UUID from the JWT. The framework also enforces identity scope on input fields likeuser_idandowner_idautomatically. - Refresh tokens are single-use. Each call to
rotate_refresh_tokeninvalidates the old token and returns a fresh pair. If a token is reused, Forge rejects it -- this limits the damage from token theft.