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
| Attribute | Type | Default | Description |
|---|---|---|---|
public | flag | false | Skip 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:
- Your auth provider issues a JWT with a
tenant_idclaim (a UUID). - The gateway validates the token and makes
ctx.auth.tenant_id()available in every handler. - If your SQL uses
tenant_idin a WHERE clause, the macro detects this at compile time and setsrequires_tenant_scope = trueon the function. The runtime then rejects calls that lack atenant_idclaim with 403 Forbidden — before your handler runs. - You apply the filter in SQL using the claim value.
Isolation modes are available via TenantContext when you need finer control inside a handler:
| Mode | Constant | Behaviour |
|---|---|---|
none | TenantIsolationMode::None | No tenant isolation; global access. Default. |
strict | TenantIsolationMode::Strict | Only see own tenant's data. Reads and writes are scoped. |
read_shared | TenantIsolationMode::ReadShared | Reads 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:
public | unscoped | |
|---|---|---|
| JWT required | No — anonymous access allowed | Yes — valid token still required |
| Scope check | Skipped (no identity to check against) | Skipped explicitly |
| Use for | Login endpoints, public APIs, landing page data | Admin 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
| Method | Returns | Description |
|---|---|---|
auth.is_authenticated() | bool | Check 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") | bool | Check 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() | bool | Check 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"
| Option | Type | Default | Description |
|---|---|---|---|
jwt_algorithm | string | HS256 | HS256, HS384, HS512, RS256, RS384, RS512 |
jwt_secret | string | - | Secret for HMAC algorithms |
jwks_url | string | - | JWKS endpoint for RSA algorithms |
jwks_cache_ttl_secs | u64 | 3600 | How long to cache public keys |
jwt_issuer | string | - | Expected issuer (validated if set) |
jwt_audience | string | - | 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
| Method | Description |
|---|---|
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:
- Extract - Token from
Authorization: Bearer <token>header - Decode - Parse JWT header and payload
- Validate required claims - Both
expandsubclaims must be present - Validate expiry - Check
expclaim against current time (60s leeway for clock skew) - Validate issuer - If configured, check
issclaim matches - Validate audience - If configured, check
audclaim matches - 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(_))));
}