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.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:
- 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.
- User scoping is enforced at compile time. Private queries must filter by
user_idorowner_idin their SQL WHERE clause. Usectx.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_tokeninvalidates 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
- Frontend redirects user to provider (Google, GitHub, etc.)
- Provider redirects back with an authorization code
- Frontend sends the code to your mutation
- Mutation exchanges code for provider's access token (server-side)
- Mutation fetches user info from provider
- Mutation upserts user in your database
- Mutation calls
ctx.issue_token_pair()to create Forge tokens - Frontend stores tokens and uses them like any other Forge auth
Example: Google Sign-In
Add a google_sub column to your users table:
-- @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:
#[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
- SvelteKit
- Dioxus
Use the Google Sign-In SDK to get an authorization code, then call your mutation:
<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}
use dioxus::prelude::*;
use forge_dioxus::prelude::*;
use web_sys::window;
static GOOGLE_CLIENT_ID: &str = env!("GOOGLE_CLIENT_ID");
fn get_redirect_uri() -> String {
let origin = window().unwrap().location().origin().unwrap();
format!("{origin}/login")
}
#[component]
pub fn LoginPage() -> Element {
let mut auth = use_forge_auth();
let mut error = use_signal(|| None::<String>);
// Check for OAuth callback
use_effect(move || {
let url = window().unwrap().location().search().unwrap();
if let Some(code) = url.strip_prefix("?code=") {
let code = code.split('&').next().unwrap().to_string();
spawn(async move {
match google_sign_in(GoogleSignInInput {
code,
redirect_uri: get_redirect_uri(),
}).await {
Ok(res) => {
auth.login_with_viewer(res.access_token, res.refresh_token, &res.user);
navigator().push(Route::Home);
}
Err(e) => error.set(Some(e.message)),
}
});
}
});
let start_google_login = move |_| {
let params = format!(
"client_id={}&redirect_uri={}&response_type=code&scope=openid%20email%20profile",
GOOGLE_CLIENT_ID,
get_redirect_uri()
);
window().unwrap()
.location()
.set_href(&format!("https://accounts.google.com/o/oauth2/v2/auth?{params}"))
.unwrap();
};
rsx! {
button { onclick: start_google_login, "Sign in with Google" }
if let Some(msg) = error() {
p { class: "error", "{msg}" }
}
}
}
Setup Checklist
- Create a project in Google Cloud Console
- Enable the Google+ API (for userinfo endpoint)
- Create OAuth 2.0 credentials (Web application)
- Add your redirect URIs (e.g.,
http://localhost:9080/login,https://yourapp.com/login) - Set environment variables:
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret - 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:
| Provider | Token Endpoint | UserInfo Endpoint |
|---|---|---|
https://oauth2.googleapis.com/token | https://www.googleapis.com/oauth2/v2/userinfo | |
| GitHub | https://github.com/login/oauth/access_token | https://api.github.com/user |
| Microsoft | https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token | https://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.