Skip to main content

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 bcrypt crate 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.

src/schema/user.rs
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:

src/functions/auth.rs
#[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.

src/functions/auth.rs
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

src/functions/auth.rs
#[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:

src/functions/auth.rs
#[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:

Forge generates an auth.svelte.ts store that handles localStorage persistence, token refresh, and SSE reconnection.

Root layout -- start the refresh loop:

src/routes/+layout.svelte
<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:

src/routes/login/+page.svelte
<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:

src/routes/dashboard/+page.svelte
<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>

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_ttl and refresh_token_ttl in forge.toml.
  • Refresh rotation is automatic. The SvelteKit store runs a refresh loop every 40 minutes by default. The Dioxus ForgeAuthProvider does the same via refresh_interval_secs. Set the interval to roughly 2/3 of your access_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 like user_id and owner_id automatically.
  • Refresh tokens are single-use. Each call to rotate_refresh_token invalidates the old token and returns a fresh pair. If a token is reused, Forge rejects it -- this limits the damage from token theft.