This document covers the REST API for housecarl authorization. For gRPC API documentation, see the API Reference.
Housecarl provides a simple REST API for authorization checks. This is ideal for applications that want a lightweight HTTP interface without the overhead of gRPC.
https://your-housecarl-instance.com
By default, the REST API listens on gRPC port + 1. For example, if gRPC is on 1234, REST is on 1235. If you set rest_api.port, use that value instead.
The primary authorization endpoint. Use this to ask "can this subject perform this action on this object?"
POST /v1/authz/check
POST /v1/authz/check requires two things:
Authorization headerSigned-By and Date-Filed-In headersInclude your Bearer token like this:
Authorization: Bearer <your-jwt-token>
Then sign the exact JSON body you send. The server verifies a time-bucketed HMAC-SHA256 signature to prevent replay attacks.
The easiest ways to get the required credentials are:
Login gRPC endpoint and keep both the returned token and signing_secrethousectl config login, then read token and signing_secret from ~/.config/housecarl.tomlAPI Keys
API keys are the recommended production credential for REST calls. They give you a long-lived Bearer token plus the signing secret needed for request signatures.
The request body must be JSON with a context object containing three required keys:
{
"context": {
"subject": "user:alice",
"action": "read",
"object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf"
}
}
| Field | Type | Description | Example |
|---|---|---|---|
subject | string | The principal attempting the action (who) | "user:alice", "service:billing-api" |
action | string | The action being attempted (what) | "read", "write", "delete" |
object | string | The resource being accessed (where) - must be a housecarl URL | "hc://domain/<project-alpha-domain-uuid>/documents/file.txt" |
You can include additional context fields for policy evaluation:
{
"context": {
"subject": "user:alice",
"action": "read",
"object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
"department": "engineering",
"time_of_day": "business_hours",
"ip_address": "192.168.1.100"
}
}
These additional fields can be used in policy rules to make authorization decisions based on attributes.
REST requests use plain strings (or string arrays) in the context map:
{
"context": {
"subject": "user:alice",
"action": "read",
"object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
"group": "engineering"
}
}
gRPC requests use RequestValue wrappers. Use Single for scalar values and Multiple for arrays:
{
"context": {
"subject": {"Single": "user:alice"},
"action": {"Single": "read"},
"object": {"Single": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf"},
"group": {"Multiple": ["engineering"]}
}
}
Note: The REST API does not accept Single/Multiple wrappers. Use plain strings or string arrays for REST.
Context values can be arrays of strings:
{
"context": {
"subject": "user:alice",
"action": "read",
"object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
"group": ["engineering", "managers", "security-cleared"]
}
}
Success (200 OK)
{
"allowed": true
}
or
{
"allowed": false
}
Error Responses
All errors follow this format:
{
"error": "error_code",
"message": "Human-readable error description"
}
| Status Code | Error Code | Description |
|---|---|---|
| 400 Bad Request | invalid_request | Missing required fields or invalid JSON |
| 401 Unauthorized | unauthorized | Missing or invalid JWT token |
| 403 Forbidden | forbidden | JWT lacks tenant context |
| 429 Too Many Requests | rate_limited | Rate limit exceeded |
| 500 Internal Server Error | internal_error | Server-side error |
These examples assume:
HOUSECARL_DOMAIN_ID.user:alice to read hc://domain/<project-alpha-domain-uuid>/documents/report.pdf.HOUSECARL_TOKEN and HOUSECARL_SIGNING_SECRET available.If you use different subjects or objects, update the policy accordingly.
Before sending live requests:
export HOUSECARL_DOMAIN_ID=<real-domain-uuid>
export HOUSECARL_TOKEN=<bearer-token>
export HOUSECARL_SIGNING_SECRET=<base64-signing-secret>
If you leave <project-alpha-domain-uuid> unchanged in a live request, Housecarl rejects the request before policy evaluation.
This is the reference flow that was validated against a live local Housecarl instance. In Cargo.toml:
base64 = "0.22"
hex = "0.4"
hmac = "0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
ureq = { version = "3", features = ["json"] }
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize)]
struct CheckAuthorizationRequest {
context: HashMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct CheckAuthorizationResponse {
allowed: bool,
}
fn sign_request(
body: &[u8],
signing_secret_b64: &str,
timestamp: u64,
) -> Result<String, Box<dyn std::error::Error>> {
let secret = base64::engine::general_purpose::STANDARD.decode(signing_secret_b64)?;
let time_bucket = timestamp / 300;
let message = format!(
"housecarl-request-v1:{}:{}:{}",
body.len(),
hex::encode(body),
time_bucket
);
let mut mac = HmacSha256::new_from_slice(&secret)?;
mac.update(message.as_bytes());
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
}
fn check_authorization(
base_url: &str,
jwt_token: &str,
signing_secret: &str,
subject: &str,
action: &str,
object: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut context = HashMap::new();
context.insert("subject".to_string(), serde_json::Value::String(subject.to_string()));
context.insert("action".to_string(), serde_json::Value::String(action.to_string()));
context.insert("object".to_string(), serde_json::Value::String(object.to_string()));
let request = CheckAuthorizationRequest { context };
let body = serde_json::to_vec(&request)?;
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let signature = sign_request(&body, signing_secret, timestamp)?;
let response = ureq::post(&format!("{}/v1/authz/check", base_url))
.header("Authorization", &format!("Bearer {}", jwt_token))
.header("Signed-By", &signature)
.header("Date-Filed-In", ×tamp.to_string())
.header("Content-Type", "application/json")
.send(&body)?;
let response_text = response.into_body().read_to_string()?;
let check_response: CheckAuthorizationResponse = serde_json::from_str(&response_text)?;
Ok(check_response.allowed)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base_url = "http://localhost:50052";
let jwt_token = std::env::var("HOUSECARL_TOKEN")?;
let signing_secret = std::env::var("HOUSECARL_SIGNING_SECRET")?;
let domain_id = std::env::var("HOUSECARL_DOMAIN_ID")?;
let allowed = check_authorization(
base_url,
&jwt_token,
&signing_secret,
"user:alice",
"read",
&format!("hc://domain/{}/documents/report.pdf", domain_id),
)?;
if allowed {
println!("Access granted");
} else {
println!("Access denied");
}
Ok(())
}
The Go, Python, and curl examples below were also validated against a fresh local Housecarl stack. They use the same Signed-By and Date-Filed-In signing flow as the Rust sample above.
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type CheckAuthorizationRequest struct {
Context map[string]interface{} `json:"context"`
}
type CheckAuthorizationResponse struct {
Allowed bool `json:"allowed"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func signRequest(body []byte, signingSecretB64 string, timestamp int64) (string, error) {
secret, err := base64.StdEncoding.DecodeString(signingSecretB64)
if err != nil {
return "", fmt.Errorf("decode signing secret: %w", err)
}
timeBucket := timestamp / 300
message := fmt.Sprintf(
"housecarl-request-v1:%d:%s:%d",
len(body),
hex.EncodeToString(body),
timeBucket,
)
mac := hmac.New(sha256.New, secret)
if _, err := mac.Write([]byte(message)); err != nil {
return "", fmt.Errorf("sign request: %w", err)
}
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
func CheckAuthorization(baseURL, jwtToken, signingSecret, subject, action, object string) (bool, error) {
reqBody := CheckAuthorizationRequest{
Context: map[string]interface{}{
"subject": subject,
"action": action,
"object": object,
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return false, fmt.Errorf("failed to marshal request: %w", err)
}
timestamp := time.Now().Unix()
signature, err := signRequest(jsonData, signingSecret, timestamp)
if err != nil {
return false, err
}
req, err := http.NewRequest("POST", baseURL+"/v1/authz/check", bytes.NewBuffer(jsonData))
if err != nil {
return false, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwtToken)
req.Header.Set("Signed-By", signature)
req.Header.Set("Date-Filed-In", fmt.Sprintf("%d", timestamp))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return false, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return false, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return false, fmt.Errorf("%s: %s", errResp.Error, errResp.Message)
}
var checkResp CheckAuthorizationResponse
if err := json.Unmarshal(body, &checkResp); err != nil {
return false, fmt.Errorf("failed to unmarshal response: %w", err)
}
return checkResp.Allowed, nil
}
func main() {
baseURL := "http://localhost:50052"
jwtToken := os.Getenv("HOUSECARL_TOKEN")
signingSecret := os.Getenv("HOUSECARL_SIGNING_SECRET")
domainID := os.Getenv("HOUSECARL_DOMAIN_ID")
allowed, err := CheckAuthorization(
baseURL,
jwtToken,
signingSecret,
"user:alice",
"read",
fmt.Sprintf("hc://domain/%s/documents/report.pdf", domainID),
)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
if allowed {
fmt.Println("Access granted")
} else {
fmt.Println("Access denied")
}
}
import base64
import hashlib
import hmac
import json
import os
import requests
from typing import Dict, Any
import time
class HousecarlClient:
def __init__(self, base_url: str, jwt_token: str, signing_secret: str):
self.base_url = base_url
self.jwt_token = jwt_token
self.signing_secret = signing_secret
self.session = requests.Session()
self.session.headers.update({'Content-Type': 'application/json'})
def _sign_request(self, body: bytes, timestamp: int) -> str:
secret = base64.b64decode(self.signing_secret)
time_bucket = timestamp // 300
message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{time_bucket}".encode("utf-8")
digest = hmac.new(secret, message, hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def check_authorization(
self,
subject: str,
action: str,
object_url: str,
extra_context: Dict[str, Any] = None
) -> bool:
"""
Check if a subject can perform an action on an object.
Args:
subject: The principal (e.g., "user:alice")
action: The action (e.g., "read", "write")
object_url: The housecarl resource URL (e.g., "hc://domain/<project-alpha-domain-uuid>/resource")
extra_context: Optional additional context for policy evaluation
Returns:
True if authorized, False if denied
Raises:
requests.HTTPError: On error responses
"""
context = {
"subject": subject,
"action": action,
"object": object_url
}
# Add extra context if provided
if extra_context:
context.update(extra_context)
request_body = {"context": context}
body = json.dumps(request_body, separators=(",", ":")).encode("utf-8")
timestamp = int(time.time())
signature = self._sign_request(body, timestamp)
response = self.session.post(
f"{self.base_url}/v1/authz/check",
data=body,
headers={
'Authorization': f'Bearer {self.jwt_token}',
'Signed-By': signature,
'Date-Filed-In': str(timestamp),
},
)
response.raise_for_status()
return response.json().get("allowed", False)
if __name__ == "__main__":
base_url = "http://localhost:50052"
jwt_token = os.environ["HOUSECARL_TOKEN"]
signing_secret = os.environ["HOUSECARL_SIGNING_SECRET"]
domain_id = os.environ["HOUSECARL_DOMAIN_ID"]
client = HousecarlClient(base_url, jwt_token, signing_secret)
try:
allowed = client.check_authorization(
subject="user:alice",
action="read",
object_url=f"hc://domain/{domain_id}/documents/report.pdf"
)
if allowed:
print("Access granted")
else:
print("Access denied")
except requests.HTTPError as e:
print(f"Error: {e}")
if e.response is not None:
error_body = e.response.json()
print(f"Error code: {error_body.get('error')}")
print(f"Message: {error_body.get('message')}")
BODY="{\"context\":{\"subject\":\"user:alice\",\"action\":\"read\",\"object\":\"hc://domain/${HOUSECARL_DOMAIN_ID}/documents/report.pdf\"}}"
TIMESTAMP=$(date +%s)
SIGNATURE=$(BODY="$BODY" HOUSECARL_SIGNING_SECRET="$HOUSECARL_SIGNING_SECRET" TIMESTAMP="$TIMESTAMP" python - <<'PY'
import base64
import hashlib
import hmac
import os
body = os.environ["BODY"].encode()
secret = base64.b64decode(os.environ["HOUSECARL_SIGNING_SECRET"])
timestamp = int(os.environ["TIMESTAMP"])
message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{timestamp // 300}".encode()
print(base64.b64encode(hmac.new(secret, message, hashlib.sha256).digest()).decode(), end="")
PY
)
curl -X POST http://localhost:50052/v1/authz/check \
-H "Authorization: Bearer ${HOUSECARL_TOKEN}" \
-H "Signed-By: ${SIGNATURE}" \
-H "Date-Filed-In: ${TIMESTAMP}" \
-H "Content-Type: application/json" \
--data "${BODY}"
# Response: {"allowed":true}
To use the REST API, you need both a token and a signing secret. Obtain them by calling the Login gRPC endpoint:
Login gRPC method with username and passwordsigning_secret in the responseAuthorization: Bearer ...Signed-By and Date-Filed-InSee the API Reference for details on the Login method.
REST calls require a tenant-scoped token. If your token lacks tenant context, call RefreshLoginWithTenant (see the API Reference) to upgrade the session first.
For production services, create a service account and use its API key token as a Bearer token for REST or gRPC.
The /v1/authz/check endpoint uses a burst rate limiter to prevent traffic spikes. If you exceed the rate limit, you'll receive a 429 Too Many Requests response:
{
"error": "rate_limited",
"message": "rate limit exceeded: Burst rate limit exceeded: 1000 requests in 100ms"
}
The burst limiter uses a sliding window algorithm to track requests per tenant:
Example: If you send 1000 requests instantly, the 1001st request will be rate-limited. After 100ms, old requests expire from the window and you can send more.
Currently, there is no API endpoint to check your remaining burst capacity. The rate limiter rejects requests when exceeded and returns a 429 error.
Note: The gRPC API also has a token-based quota system for sustained rate limiting. This is not currently available for REST endpoints. If you need higher sustained throughput, consider using the gRPC API (see API Reference).
To avoid rate limiting:
Cache authorization decisions when appropriate for your security model
Implement exponential backoff when you receive 429 errors
Wait 100ms → retry
Wait 200ms → retry
Wait 400ms → retry
Batch authorization checks when possible
Spread requests over time rather than sending bursts
Monitor your 429 error rate and alert if it exceeds expected levels
Always handle errors gracefully:
try:
allowed = client.check_authorization(subject, action, object_url)
if allowed:
# Perform the action
pass
else:
# Deny access
return 403
except requests.HTTPError as e:
if e.response.status_code == 401:
# Token expired or invalid - re-authenticate
refresh_jwt_token()
elif e.response.status_code == 429:
# Rate limited - back off and retry
time.sleep(1)
retry_with_backoff()
else:
# Other errors - fail securely (deny access)
return 500
All authorization checks are logged to the audit service and include:
You can query audit logs via the audit service API or UI.
Housecarl provides a gRPC API for all operations - authorization checks, user management, tenant management, and policy administration. This guide covers the API architecture, authentication, and common usage patterns.