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.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<()> {
// ...
}

Private functions get the authenticated user via ctx.user_id(). The framework enforces at compile time that private queries filter by user identity in SQL. Use #[query(unscoped)] to opt out for shared data.

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.
  • User scoping is enforced at compile time. Private queries must filter by user_id or owner_id in their SQL WHERE clause. Use ctx.user_id() to get the authenticated user's UUID. The macro rejects queries that reference tables without identity filtering. Use #[query(unscoped)] to opt out.
  • 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.

Social Login (Google, GitHub, etc.)

Forge's auth system works with any identity provider. Instead of password validation, you exchange an OAuth authorization code for user info, then issue a Forge JWT. No Firebase or external auth service required.

How It Works

  1. Frontend redirects user to provider (Google, GitHub, etc.)
  2. Provider redirects back with an authorization code
  3. Frontend sends the code to your mutation
  4. Mutation exchanges code for provider's access token (server-side)
  5. Mutation fetches user info from provider
  6. Mutation upserts user in your database
  7. Mutation calls ctx.issue_token_pair() to create Forge tokens
  8. Frontend stores tokens and uses them like any other Forge auth

Example: Google Sign-In

Add a google_sub column to your users table:

migrations/0002_google_auth.sql
-- @up
ALTER TABLE users ADD COLUMN google_sub TEXT UNIQUE;
CREATE INDEX idx_users_google_sub ON users(google_sub);

-- @down
DROP INDEX idx_users_google_sub;
ALTER TABLE users DROP COLUMN google_sub;

Create the sign-in mutation:

src/functions/auth.rs
#[derive(Debug, serde::Deserialize)]
struct GoogleTokenResponse {
access_token: String,
}

#[derive(Debug, serde::Deserialize)]
struct GoogleUserInfo {
id: String,
email: String,
name: String,
picture: Option<String>,
}

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct GoogleSignInInput {
pub code: String,
pub redirect_uri: String,
}

#[forge::mutation(public)]
pub async fn google_sign_in(
ctx: &MutationContext,
input: GoogleSignInInput,
) -> Result<AuthResponse> {
// Exchange code for Google access token
let tokens: GoogleTokenResponse = ctx.http()
.post("https://oauth2.googleapis.com/token")
.form(&[
("code", input.code.as_str()),
("client_id", ctx.env_require("GOOGLE_CLIENT_ID")?.as_str()),
("client_secret", ctx.env_require("GOOGLE_CLIENT_SECRET")?.as_str()),
("redirect_uri", input.redirect_uri.as_str()),
("grant_type", "authorization_code"),
])
.send().await
.map_err(|e| ForgeError::Internal(format!("Google token exchange failed: {e}")))?
.json().await
.map_err(|e| ForgeError::Internal(format!("Invalid Google response: {e}")))?;

// Fetch user info
let info: GoogleUserInfo = ctx.http()
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.bearer_auth(&tokens.access_token)
.send().await
.map_err(|e| ForgeError::Internal(format!("Google userinfo failed: {e}")))?
.json().await
.map_err(|e| ForgeError::Internal(format!("Invalid userinfo response: {e}")))?;

// Upsert user
let mut conn = ctx.conn().await?;
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, google_sub, email, name, role, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, $3, $4, now(), now())
ON CONFLICT (google_sub) DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name,
updated_at = now()
RETURNING id, email, name, role as "role: UserRole",
password_hash, created_at, updated_at
"#,
info.id,
info.email,
info.name,
UserRole::Member as UserRole
)
.fetch_one(&mut conn)
.await?;
drop(conn);

// Issue Forge tokens
auth_response(ctx, &user).await
}

Frontend Integration

Use the Google Sign-In SDK to get an authorization code, then call your mutation:

src/routes/login/+page.svelte
<script lang="ts">
import { googleSignIn } from "$lib/forge/api";
import { auth } from "$lib/forge/auth.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";

const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const REDIRECT_URI = `${window.location.origin}/login`;

let error = $state("");

function startGoogleLogin() {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}

onMount(async () => {
const code = new URLSearchParams(window.location.search).get("code");
if (code) {
try {
const res = await googleSignIn({ code, redirect_uri: REDIRECT_URI });
auth.setAuth(res.access_token, res.refresh_token, res.user);
goto("/");
} catch (e) {
error = e.message;
}
}
});
</script>

<button onclick={startGoogleLogin}>Sign in with Google</button>
{#if error}<p class="error">{error}</p>{/if}

Setup Checklist

  1. Create a project in Google Cloud Console
  2. Enable the Google+ API (for userinfo endpoint)
  3. Create OAuth 2.0 credentials (Web application)
  4. Add your redirect URIs (e.g., http://localhost:9080/login, https://yourapp.com/login)
  5. Set environment variables:
    GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
    GOOGLE_CLIENT_SECRET=your-client-secret
  6. For the frontend, expose only the client ID via Vite/build config

Other Providers

The pattern is identical for GitHub, Apple, Microsoft, etc. Change the endpoints:

ProviderToken EndpointUserInfo Endpoint
Googlehttps://oauth2.googleapis.com/tokenhttps://www.googleapis.com/oauth2/v2/userinfo
GitHubhttps://github.com/login/oauth/access_tokenhttps://api.github.com/user
Microsofthttps://login.microsoftonline.com/{tenant}/oauth2/v2.0/tokenhttps://graph.microsoft.com/v1.0/me

Use the provider's stable user ID (sub for Google, id for GitHub) as your unique key, not email. Emails can change.