Skip to main content

Protect Routes

Define authentication and authorization requirements for function calls.

The Code

#[forge::query]
pub async fn get_user(ctx: &QueryContext, id: Uuid) -> Result<User> {
let user_id = ctx.auth.require_user_id()?; // Returns 401 if not authenticated

sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_one(ctx.db())
.await
.map_err(Into::into)
}

What Happens

All functions require authentication by default. The JWT token from the Authorization: Bearer <token> header is validated before your code runs. Missing or invalid tokens return 401 Unauthorized.

Token validation checks expiry, signature, and optionally issuer/audience. Keys are cached to avoid latency on every request.

Attributes

AttributeTypeDefaultDescription
publicflagfalseSkip authentication for this function
require_role("admin")string-Require the specified role (returns 403 if missing)

Patterns

Public Endpoints

#[forge::query(public)]
pub async fn get_public_stats(ctx: &QueryContext) -> Result<Stats> {
// Public endpoint; no token required
sqlx::query_as!(Stats, "SELECT count(*) as total FROM orders")
.fetch_one(ctx.db())
.await
.map_err(Into::into)
}

Role-Based Access

#[forge::mutation(require_role("admin"))]
pub async fn delete_user(ctx: &MutationContext, id: Uuid) -> Result<()> {
// Only users with "admin" role can call this
// Returns 403 Forbidden if role is missing
sqlx::query!("DELETE FROM users WHERE id = $1", id)
.execute(ctx.db())
.await?;
Ok(())
}

Custom Role Resolution

By default, require_role checks the flat roles list from the JWT claim. If you need role hierarchy expansion, group membership lookups, or any other custom logic, implement RoleResolver and register it on the builder.

use std::sync::Arc;
use forge_core::{RoleResolver, AuthContext};

struct HierarchyResolver;

impl RoleResolver for HierarchyResolver {
fn resolve(&self, auth: &AuthContext) -> Vec<String> {
let mut roles = auth.roles().to_vec();
// Admins implicitly hold all lower-privileged roles
if roles.contains(&"admin".to_string()) {
roles.extend(["editor", "viewer"].map(String::from));
}
roles
}
}

Forge::builder()
.with_role_resolver(Arc::new(HierarchyResolver))
.build()?
.run()
.await

resolve is called once per request that carries a require_role constraint. The returned list is not cached between calls, so keep implementations cheap — cache remote lookups inside the resolver struct.

Checking Roles in Code

#[forge::query]
pub async fn get_orders(ctx: &QueryContext) -> Result<Vec<Order>> {
let user_id = ctx.auth.require_user_id()?;

// Admins see all orders, regular users see only their own
if ctx.auth.has_role("admin") {
sqlx::query_as!(Order, "SELECT * FROM orders")
.fetch_all(ctx.db())
.await
.map_err(Into::into)
} else {
sqlx::query_as!(Order,
"SELECT * FROM orders WHERE user_id = $1",
user_id
)
.fetch_all(ctx.db())
.await
.map_err(Into::into)
}
}

Tenant Isolation

For multi-tenant products, Forge enforces a tenant_id claim from the JWT and provides helpers for filtering data by tenant.

How tenant_id flows through the system:

  1. Your auth provider issues a JWT with a tenant_id claim (a UUID).
  2. The gateway validates the token and makes ctx.auth.tenant_id() available in every handler.
  3. If your SQL uses tenant_id in a WHERE clause, the macro detects this at compile time and sets requires_tenant_scope = true on the function. The runtime then rejects calls that lack a tenant_id claim with 403 Forbidden — before your handler runs.
  4. You apply the filter in SQL using the claim value.

Isolation modes are available via TenantContext when you need finer control inside a handler:

ModeConstantBehaviour
noneTenantIsolationMode::NoneNo tenant isolation; global access. Default.
strictTenantIsolationMode::StrictOnly see own tenant's data. Reads and writes are scoped.
read_sharedTenantIsolationMode::ReadSharedReads may include shared/global rows; writes are tenant-scoped.
use forge_core::{TenantContext, TenantIsolationMode};

#[forge::query]
pub async fn list_documents(ctx: &QueryContext) -> Result<Vec<Document>> {
let tenant_id = ctx.auth.tenant_id()
.ok_or_else(|| ForgeError::Forbidden("tenant_id claim required".into()))?;

// TenantContext::strict() builds a context with TenantIsolationMode::Strict.
// sql_filter() returns a parameterized clause + the bound UUID — safe against injection.
let tenant_ctx = TenantContext::strict(tenant_id);
let (clause, id) = tenant_ctx
.sql_filter("tenant_id", 1)
.expect("strict context always produces a filter");

// Use the clause and bound value in your query.
// In practice, embed tenant_id directly as a bind param (shown below).
sqlx::query_as!(
Document,
"SELECT * FROM documents WHERE tenant_id = $1",
tenant_id,
)
.fetch_all(ctx.db())
.await
.map_err(Into::into)
}

For ReadShared — where some rows are global (no tenant) and others are tenant-scoped:

#[forge::query]
pub async fn list_templates(ctx: &QueryContext) -> Result<Vec<Template>> {
let tenant_id = ctx.auth.tenant_id()
.ok_or_else(|| ForgeError::Forbidden("tenant_id claim required".into()))?;

// Return global templates (tenant_id IS NULL) + this tenant's own templates.
sqlx::query_as!(
Template,
"SELECT * FROM templates WHERE tenant_id IS NULL OR tenant_id = $1",
tenant_id,
)
.fetch_all(ctx.db())
.await
.map_err(Into::into)
}

Compile-time enforcement: When a private (non-public, non-unscoped) query contains tenant_id in its SQL WHERE clause, the runtime enforces that the caller's JWT includes a valid tenant_id claim. No tenant claim → 403 before your handler runs. Use #[query(unscoped)] to opt out for admin queries that span all tenants.

Testing with a tenant context:

#[tokio::test]
async fn test_tenant_query() {
let tenant_id = Uuid::new_v4();
let ctx = TestQueryContext::builder()
.as_user(Uuid::new_v4())
.with_tenant(tenant_id)
.build();

let result = list_documents(&ctx).await;
assert_ok!(result);
}

Compile-Time Scope Enforcement

Private queries must filter by user_id or owner_id in their SQL. The #[query] macro checks this at compile time and rejects queries that reference tables without filtering by user identity.

// ✅ Compiles: SQL filters by user_id
#[forge::query]
pub async fn list_orders(ctx: &QueryContext) -> Result<Vec<Order>> {
let user_id = ctx.user_id();
sqlx::query_as!(Order,
"SELECT * FROM orders WHERE user_id = $1",
user_id
)
.fetch_all(ctx.db())
.await
.map_err(Into::into)
}

// ❌ Compile error: no user_id/owner_id in WHERE clause
#[forge::query]
pub async fn list_all_orders(ctx: &QueryContext) -> Result<Vec<Order>> {
sqlx::query_as!(Order, "SELECT * FROM orders")
.fetch_all(ctx.db()).await.map_err(Into::into)
}

// ✅ Opt out explicitly for admin/shared data queries
#[forge::query(unscoped)]
pub async fn admin_list_orders(ctx: &QueryContext) -> Result<Vec<Order>> {
sqlx::query_as!(Order, "SELECT * FROM orders")
.fetch_all(ctx.db()).await.map_err(Into::into)
}

Public queries skip this check since they don't require authentication.

public vs unscoped

These two flags look similar but control different things:

publicunscoped
JWT requiredNo — anonymous access allowedYes — valid token still required
Scope checkSkipped (no identity to check against)Skipped explicitly
Use forLogin endpoints, public APIs, landing page dataAdmin queries, shared/global data that any authenticated user may read

public bypasses JWT validation entirely. The request reaches your handler with no auth context — ctx.auth.user_id() returns None.

unscoped keeps authentication in place but tells the compiler not to enforce the user_id/owner_id filter rule. The caller must still present a valid JWT. Use it when your SQL intentionally spans all users (e.g., SELECT * FROM products — a catalogue that every logged-in user can browse).

// Anonymous access — no token needed
#[forge::query(public)]
pub async fn get_pricing(ctx: &QueryContext) -> Result<Vec<Plan>> { ... }

// Auth required, but not scoped to a single user
#[forge::query(unscoped)]
pub async fn list_all_products(ctx: &QueryContext) -> Result<Vec<Product>> { ... }

Combining both (public + unscoped) is redundant: public already implies no scope check.

Accessing Custom Claims

#[forge::query]
pub async fn get_tenant_data(ctx: &QueryContext) -> Result<TenantData> {
// Access custom JWT claims
let org_id = ctx.auth
.claim("org_id")
.and_then(|v| v.as_str())
.ok_or_else(|| ForgeError::Forbidden("Missing org_id claim".into()))?;

sqlx::query_as!(TenantData,
"SELECT * FROM tenant_data WHERE org_id = $1",
org_id
)
.fetch_one(ctx.db())
.await
.map_err(Into::into)
}

Non-UUID Auth Providers

For providers like Firebase or Clerk that use string identifiers instead of UUIDs:

#[forge::query]
pub async fn get_profile(ctx: &QueryContext) -> Result<Profile> {
// Use subject() for Firebase uid, Clerk user_xxx, or email subjects
let subject = ctx.auth.require_subject()?;

sqlx::query_as!(Profile,
"SELECT * FROM profiles WHERE external_id = $1",
subject
)
.fetch_one(ctx.db())
.await
.map_err(Into::into)
}

Context Methods

MethodReturnsDescription
auth.is_authenticated()boolCheck if request has valid token
auth.user_id()Option<Uuid>Get user ID (UUID format only)
auth.require_user_id()Result<Uuid>Get user ID or return 401
auth.subject()Option<&str>Get raw subject claim (any format)
auth.require_subject()Result<&str>Get subject or return 401
auth.has_role("role")boolCheck if user has role
auth.require_role("role")Result<()>Require role or return 403
auth.claim("key")Option<&Value>Get custom claim by key
auth.claims()&HashMap<String, Value>Get all custom JWT claims
auth.tenant_id()Option<Uuid>Read the tenant_id custom claim as a UUID. Returns None if the claim is absent or not a valid UUID
auth.roles()&[String]Get all roles
auth.principal_id()Option<String>Stable principal identifier (prefers sub, falls back to user_id). Used for ownership tracking in jobs and workflows.
auth.is_admin()boolCheck if principal has the "admin" role

Configuration

Configure authentication in forge.toml:

HMAC Algorithms (Symmetric)

[auth]
jwt_algorithm = "HS256" # or HS384, HS512
jwt_secret = "${JWT_SECRET}"
jwt_issuer = "my-app" # Optional: validate issuer
jwt_audience = "my-api" # Optional: validate audience

RSA Algorithms with JWKS (Asymmetric)

For Firebase, Clerk, Auth0, and other providers:

[auth]
jwt_algorithm = "RS256" # or RS384, RS512
jwks_url = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
jwks_cache_ttl_secs = 3600 # Default: 1 hour
jwt_issuer = "https://securetoken.google.com/my-project"
jwt_audience = "my-project"
OptionTypeDefaultDescription
jwt_algorithmstringHS256HS256, HS384, HS512, RS256, RS384, RS512
jwt_secretstring-Secret for HMAC algorithms
jwks_urlstring-JWKS endpoint for RSA algorithms
jwks_cache_ttl_secsu643600How long to cache public keys
jwt_issuerstring-Expected issuer (validated if set)
jwt_audiencestring-Expected audience (validated if set)

Frontend Auth Store

When auth is configured, Forge generates a Svelte auth store with localStorage persistence:

import { auth, getToken } from '$lib/forge';

// After login (with refresh token)
auth.setAuth(accessToken, refreshToken, { id: user.id, email: user.email });

// Check auth state
if (auth.isAuthenticated) {
console.log(auth.user.email);
}

// Update tokens after refresh
auth.updateTokens(newAccessToken, newRefreshToken);

// Start automatic refresh loop (calls your refresh mutation periodically)
auth.startRefreshLoop('http://localhost:9081', 40 * 60 * 1000); // every 40 minutes

// On logout
auth.clearAuth();

// The client uses getToken() automatically for requests

The generated store:

interface AuthStore {
token: string | null; // Current access token
refreshToken: string | null; // Current refresh token
user: User | null; // User object you passed to setAuth
isAuthenticated: boolean; // true when token exists
setAuth(token: string, refreshToken: string, user: User): void;
updateTokens(token: string, refreshToken: string): void;
updateUser(user: User): void;
clearAuth(): void;
startRefreshLoop(apiUrl: string, intervalMs?: number): void;
tryRefresh(): Promise<boolean>;
handleAuthError(): void; // Coalesces concurrent 401s
}

export const auth: AuthStore;
export function getToken(): string | null;

When setAuth() or clearAuth() is called, the client automatically reconnects with the new token.

Refresh Tokens

Forge includes a built-in refresh token system for issuing short-lived access tokens alongside longer-lived refresh tokens. The forge_refresh_tokens system table is created automatically, so no migration is needed.

Tokens are random opaque strings. They are SHA-256 hashed before storage, so the raw token value never hits the database.

Configuration

Token TTLs are set in the [auth] section of forge.toml:

[auth]
access_token_ttl = "15m" # How long access tokens are valid
refresh_token_ttl = "7d" # How long refresh tokens are valid

Issuing Tokens

ctx.issue_token_pair(user_id, &["role"]) issues an access token and a refresh token in one call, using the TTLs from your config.

Rotating Tokens

ctx.rotate_refresh_token(&old_token) atomically deletes the old refresh token and issues a new pair. Each refresh token is single-use: once rotated, the old token is invalidated immediately.

Revoking Tokens

MethodDescription
ctx.revoke_refresh_token(&token)Revoke a single refresh token
ctx.revoke_all_refresh_tokens(user_id)Revoke all refresh tokens for a user (useful for password changes or account deletion)

Example

#[forge::mutation(public)]
async fn login(ctx: &MutationContext, email: String, password: String) -> Result<TokenPair> {
let user = verify_credentials(ctx, &email, &password).await?;
ctx.issue_token_pair(user.id, &user.roles).await
}

#[forge::mutation]
async fn refresh(ctx: &MutationContext, refresh_token: String) -> Result<TokenPair> {
ctx.rotate_refresh_token(&refresh_token).await
}

Under the Hood

Token validation runs this pipeline:

  1. Extract - Token from Authorization: Bearer <token> header
  2. Decode - Parse JWT header and payload
  3. Validate required claims - Both exp and sub claims must be present
  4. Validate expiry - Check exp claim against current time (60s leeway for clock skew)
  5. Validate issuer - If configured, check iss claim matches
  6. Validate audience - If configured, check aud claim matches
  7. Verify signature - HMAC secret or RSA public key from JWKS

For RSA algorithms, public keys are fetched from the JWKS endpoint and cached. Cache TTL defaults to 1 hour. Keys are matched by the kid (key ID) header in the token.

Signature verification uses constant-time comparison to prevent timing attacks.

Testing

use forge::testing::TestQueryContext;

#[tokio::test]
async fn test_protected_query() {
let ctx = TestQueryContext::builder()
.as_user(Uuid::new_v4())
.with_role("admin")
.build();

let result = get_orders(&ctx).await;
assert!(result.is_ok());
}

#[tokio::test]
async fn test_unauthenticated() {
let ctx = TestQueryContext::builder()
.build(); // No user = unauthenticated

let result = get_user(&ctx, Uuid::new_v4()).await;
assert!(result.is_err());
}

#[tokio::test]
async fn test_missing_role() {
let ctx = TestQueryContext::builder()
.as_user(Uuid::new_v4())
.with_role("user") // Not admin
.build();

let result = delete_user(&ctx, Uuid::new_v4()).await;
assert!(matches!(result, Err(ForgeError::Forbidden(_))));
}