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(())
}

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)
}
}

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.

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(_))));
}