Hessra Logo
BACK TO DOCS
#webapp#authorization#documentation#designation#delegation#multi-tenant

Webapp Auth with Capability Security

How to use capability security for a multi-tenant webapp with dynamic resource naming, identity delegation, and designation.

by The Hessra Team

This guide covers how to use capability security for a multi-tenant webapp where resources have dynamic names. It builds on the quickstart. If you have not read that first, start there.

The problem this guide solves

The quickstart shows service-to-service capabilities where the root authority knows every resource name up front. Webapps break this assumption. When a user requests their data, they are not requesting favorite_cat, they are requesting user1/favorite_cat. When they request an order, it might be something like tenant123/orders/order-456.

The root authority cannot be the source of truth for every tenant and user your app will ever create. You would need to update it every time a new tenant signs up, every time a user is created, every time your data model gains a new layer. This is the naming problem described in what led me to capability security.

Capability security solves this through designation. The root authority grants your webapp the authority to access a resource type. Your webapp controls the naming within its own namespace and attenuates tokens with the complete designation before handing them to users. The root authority stays simple and generic. The webapp owns its own namespace.

How it works

There are two token operations your webapp performs that the quickstart does not cover:

Identity delegation: your webapp has its own identity token from the root authority. When a user authenticates, the webapp delegates a sub-identity to that user. This gives the user a verifiable identity that is cryptographically derived from the webapp's identity, scoped to that user.

Capability designation: when a user needs to access a resource, the webapp requests a capability token from the root authority for the resource type, then attenuates that token with the complete designation (tenant, user, or whatever else fully names the object) before giving it to the user.

The result: the user presents both an identity token and a designated capability token with their request. The verifier checks both. A token designated for tenant123/user1 cannot be used by tenant123/user2. A token for one tenant cannot be used in another.

Setup

Your clients.toml needs two additions for your webapp compared to the basic service-to-service case: identity tokens enabled, and the delegatable_identity_token flag set so the webapp can create sub-identities for its users.

[[clients]]
identifier = { type = "san_uri", value = "uri:urn:yourco:cat-webapp" }
resources = [
    { name = "cat_of_the_day", operations = ["read"] },
    { name = "favorite_cat", operations = ["read", "write"] },
]
identity_token_enabled = true
identity_token_duration = 7200
delegatable_identity_token = true

Getting an identity token for your webapp

At startup, your webapp requests an identity token from the root authority. This token is what it uses to create user sub-identities.

use hessra_client::{HessraClient, types::IdentityTokenRequest};

let client = HessraClient::builder()
    .base_url("yourco.hessra.net")
    .mtls_cert(include_str!("certs/webapp.pem"))
    .mtls_key(include_str!("certs/webapp.key"))
    .server_ca(include_str!("certs/ca.pem"))
    .build()?;

let identity_response = client.request_identity_token(
    &IdentityTokenRequest { identifier: None }
).await?;

let webapp_identity_token = identity_response.token.expect("no identity token");

Cache this token and refresh it before it expires. The expires_in field in the response tells you the lifetime in seconds. Another pattern to use is to manually request an identity token and deploy it with your app.

Delegating an identity to a user

When a user authenticates, delegate a sub-identity from the webapp's identity token. This uses the hessra-identity-token crate directly and requires only the root authority's public key.

[dependencies]
hessra-identity-token = "1.1"
hessra-token-core = "1.0"
use hessra_identity_token::add_identity_attenuation_to_token;
use hessra_token_core::TokenTimeConfig;

// user_subject must be a sub-path of the webapp's identity
// e.g. if the webapp is "uri:urn:yourco:cat-webapp", users are
// "uri:urn:yourco:cat-webapp:tenant123:user456"
let user_subject = format!(
    "uri:urn:yourco:cat-webapp:{}:{}",
    tenant_id, user_id
);

let user_identity_token = add_identity_attenuation_to_token(
    webapp_identity_token.clone(),
    user_subject.clone(),
    root_public_key,
    TokenTimeConfig::default(),
)?;

// Send the user_identity_token to the browser as a session cookie
// It proves who the user is without carrying any permissions

The delegated identity token is a valid Biscuit token with an additional attenuation block that restricts it to the user's sub-identity. The user cannot remove or modify this block. The webapp's identity is the root of the chain.

Requesting a designated capability for a user

When a user wants to access favorite_cat, your webapp requests a broad capability from the root authority and then attenuates it with the user's designation before returning it to the user.

[dependencies]
hessra-cap-token = "1.0"
use hessra_cap_token::DesignationBuilder;
use hessra_client::types::TokenRequest;

// Step 1: request a broad capability from the root authority
// This says "cat-webapp can read favorite_cat" -- no user info yet
let cap_response = client.request_token(&TokenRequest {
    resource: "favorite_cat".to_string(),
    operation: "read".to_string(),
}).await?;

let broad_token = cap_response.token.expect("no token");

// Step 2: attenuate with the complete designation
// This says "specifically, this user's favorite_cat at this tenant"
// DesignationBuilder only needs the public key, not the signing key
let designated_token = DesignationBuilder::from_base64(broad_token, root_public_key)?
    .designate("tenant_id".to_string(), tenant_id.to_string())
    .designate("user".to_string(), user_subject.clone())
    .attenuate_base64()?;

// Return designated_token to the user for use in their request

The user now has a capability token that names the exact object they are allowed to access. Attenuation is one-directional. The user can narrow this token further but cannot remove the tenant and user designations.

Verifying identity and capability together

The resource endpoint receives both the identity token in a cookie and the capability token as a bearer header. Verification extracts the user identity from the identity token and confirms it matches the designation in the capability token.

use hessra_cap_token::CapabilityVerifier;
use hessra_identity_token::inspect_identity_token;

async fn verify_user_request(
    identity_token: &str,    // from session cookie
    capability_token: &str,  // from Authorization: Bearer header
    tenant_id: &str,         // from request context (URL path, header, etc.)
    resource: &str,
    operation: &str,
    root_public_key: PublicKey,
) -> Result<String, AppError> {
    // Step 1: verify and inspect the identity token
    let identity = inspect_identity_token(
        identity_token.to_string(),
        root_public_key,
    )?;

    if identity.is_expired {
        return Err(AppError::Unauthorized("identity token expired".into()));
    }

    // identity.identity is the user subject, e.g.
    // "uri:urn:yourco:cat-webapp:tenant123:user456"
    let user_subject = identity.identity;

    // Step 2: verify the capability token with full designation
    // The verifier checks that the token's designation facts match
    // what we independently know about this request
    CapabilityVerifier::new(
        capability_token.to_string(),
        root_public_key,
        resource.to_string(),
        operation.to_string(),
    )
    .with_designation("tenant_id".to_string(), tenant_id.to_string())
    .with_designation("user".to_string(), user_subject.clone())
    .verify()?;

    // Both checks passed. Return the verified user subject for use
    // in the handler (audit logs, further authorization, etc.)
    Ok(user_subject)
}

If either check fails, reject the request. The capability token is useless without a matching identity. A stolen token cannot be used by a different user because the designation check would fail. If you forget to add a designation fact, like the tenant_id, the token will fail to validate. This is a big difference to things like JSON Web Tokens (JWTs) where forgetting to check a scope can easily lead to mistaken access.

This split also handles browser security cleanly. The identity token lives in an HTTP-only cookie, so JavaScript cannot read it and XSS cannot exfiltrate it. The capability token lives in client memory and is sent as an explicit Authorization header, so a CSRF attack cannot forge a valid request because cookies travel automatically on every request, but custom headers do not.

Naming your subjects

The sub-identity naming convention matters because Biscuit delegation checks use prefix matching. The user subject must start with the webapp's own identity followed by a colon. A webapp with identity uri:urn:yourco:cat-webapp can delegate to:

uri:urn:yourco:cat-webapp:tenant123
uri:urn:yourco:cat-webapp:tenant123:user456
uri:urn:yourco:cat-webapp:tenant123:user456:session789

It cannot delegate to a sibling or parent identity. This is enforced cryptographically by the Biscuit token. The attenuation block checks that the subject starts with the webapp's own identity, so a forged sub-identity that does not follow the hierarchy would fail verification.

A consistent convention across your system makes policy and audit logs easier to read. A reasonable pattern:

uri:urn:{yourco}:{service}:{tenant_id}:{user_id}

What changes with the managed service

If you are using the Hessra managed service rather than the open core, the token request and public key fetch go through hessra-client exactly as shown here. The designation and verification logic using DesignationBuilder and CapabilityVerifier is identical. Those crates talk to no external service and work the same on both paths.

The one thing that is different: identity delegation. With the managed service, the root authority mints your webapp's identity token and you delegate from there using add_identity_attenuation_to_token as shown above. With a self-hosted engine, you use CapabilityEngine::mint_identity to mint the webapp's identity directly. You also need to safeguard your signing key and distribute the public key to your verifiers, delegators, and designators.

Further reading

The webapp_auth.rs example in hessra-cap shows a fully self-contained version of this pattern with two engines in the same binary, useful for understanding the mechanics without any network setup.

The agent harness guide (coming soon) covers information flow control and how to extend this pattern to AI agent systems where you need to track data exposure across a session.