Core
Authentication
Authentication #
Husaria picks its middleware chain at startup from a single env var. There are two modes — Open and Secured — and within Secured mode you can authenticate with the admin secret, a JWT, or both. This page covers the decision tree, the JWT claim shape, and provider-specific wiring recipes for Auth0, Zitadel, and Keycloak.
Modes #
| Mode | Trigger | Behaviour |
|---|---|---|
| Open | HUSARIA_ADMIN_SECRET unset / empty and HUSARIA_ALLOW_OPEN_MODE=true | Every caller is admin. X-Husaria-Role impersonates that role with full RLS enforcement (bypass=false). JWTs are still honoured for role resolution. Startup logs Husaria is running in OPEN MODE — no admin secret configured… |
| Secured | HUSARIA_ADMIN_SECRET set | Admin secret OR validated JWT required. Missing both → 401. |
Open mode is not the silent default. If
HUSARIA_ADMIN_SECRETis unset, the server refuses to start unless you also setHUSARIA_ALLOW_OPEN_MODE=true(cmd/husaria/main.go,openModeStartupError). This removes the footgun where an accidentally-empty secret would expose a public admin bypass.
Open mode is fine for local dev and internal-network use. Never expose an open-mode instance to untrusted networks.
Decision tree (secured mode) #
- Admin-secret header matches → admin bypass,
role="admin". IfX-Husaria-Roleis also present and not"admin", the request runs as that role with full RLS enforcement (impersonation,bypass=false). - Admin-secret header present but wrong → 401. Husaria does not fall through to JWT — wrong-secret is treated as an attack signal.
- No admin secret, validated JWT present → role resolved from the JWT:
X-Husaria-Roleset: must appear inclaims.allowed_roles; otherwise 403.X-Husaria-Roleabsent:claims.default_roleis used; missing → 403.
- Neither admin secret nor JWT → 401.
Bare X-Husaria-Role headers are not accepted as authentication in secured mode.
JWT shape #
CustomClaims (internal/auth/jwt.go) — top-level (preferred) fields:
{
"user_id": "u1",
"username": "alice",
"email": "alice@example.com",
"roles": ["user"],
"allowed_roles": ["user", "manager"],
"default_role": "user",
"token_type": "access",
"exp": 1700000000,
"iat": 1699996400,
"iss": "https://issuer.example.com/",
"aud": ["husaria"]
}
Husaria also lifts the same keys out of a nested namespace when top-level fields are empty:
https://husaria.app/jwt/claims(native, preferred).https://hasura.io/jwt/claims(interop fallback).
Top-level fields always win when both forms are present. Most providers can mint custom claims at the top level — prefer that. The nested namespace is for clients you can't reshape.
Signing methods #
| Method | How to enable |
|---|---|
| RS256 + JWKS (recommended) | Set HUSARIA_JWT_JWKS_URL to your IDP's JWKS endpoint (RFC 7517). Husaria pulls keys lazily on first sight of a kid and caches them for HUSARIA_JWT_JWKS_CACHE_TTL (default 1h). Key rotation happens at the IDP without redeploying Husaria. |
| RS256 + static PEM | Set HUSARIA_JWT_PUBLIC_KEY_PATH to a PEM file. Optionally set HUSARIA_JWT_PRIVATE_KEY_PATH for token minting. If HUSARIA_JWT_JWKS_URL is also set, JWKS wins and a warning is logged — set just one to keep config clear. |
| HS256 | Set HUSARIA_JWT_SECRET. Used only when no RS256 mode is configured. |
Optional gates:
HUSARIA_JWT_ISSUER— requiredissclaim.HUSARIA_JWT_AUDIENCE— comma-separated allow-list checked against the token'saud.
Choosing between JWKS and static PEM #
Prefer JWKS whenever the IDP publishes one (Auth0, Zitadel, Keycloak, AWS Cognito, Okta, Google, …). The cache fetches on the first request that quotes a previously-unseen kid, holds the key for the TTL, and refreshes on the next miss. Operators don't have to re-export a PEM and roll the pods every time the IDP rotates.
Static PEM still makes sense when the signing key is operator-controlled (in-house issuer, air-gapped deployment) or when the IDP doesn't expose JWKS.
HUSARIA_JWT_JWKS_* env vars #
| Var | Default | Notes |
|---|---|---|
HUSARIA_JWT_JWKS_URL | unset | The full JWKS URL (e.g. https://YOUR_TENANT.us.auth0.com/.well-known/jwks.json). Takes precedence over HUSARIA_JWT_PUBLIC_KEY_PATH — if both are set, JWKS is used and a warning is logged. |
HUSARIA_JWT_JWKS_CACHE_TTL | 1h | Go duration string (15m, 2h, 30s). On a kid miss or after TTL expiry, the next verification triggers a refresh that blocks until the fetch completes (singleflight-collapsed across concurrent callers, debounced to at most one fetch per 5s). If the fetch fails but a cached key exists, the stale key is used rather than failing the request. |
ErrUnknownKID fires (and the request gets a 401) if the JWKS — even after a fresh fetch — doesn't carry the kid the token quoted. Misconfigured rotation surfaces immediately rather than as silent acceptance of a stale key.
The kid is required in the JWT header for JWKS verification. Most IDPs include it by default.
When JWKS is wired, the console Settings tab surfaces the URL and TTL (no secret material crosses the wire — both fields are public IDP metadata).
Admin-secret header #
Both X-Husaria-Admin-Secret and X-Admin-Secret are accepted.
Provider recipes #
For external identity providers, the workflow is the same on the Husaria side: configure RS256 by pointing HUSARIA_JWT_PUBLIC_KEY_PATH at the provider's signing key, set the iss / aud gates, and configure the provider to mint allowed_roles + default_role (top-level or under the husaria namespace) so the role resolution rules above work.
Auth0 #
1. Pick an audience #
Create a custom API in Auth0 (Applications → APIs → Create API) and pick an audience identifier — for example https://api.example.com. Auth0 will mint access tokens with this string in the aud claim.
2. Add a post-login Action to inject the husaria claims #
In Auth0 dashboard: Actions → Library → Build Custom → Post-Login trigger.
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://husaria.app/jwt/claims';
// Map your Auth0 roles (or app_metadata) to husaria roles.
// Adjust to your own role model — this is just a starting point.
const roles = event.authorization?.roles ?? ['user'];
const defaultRole = roles[0] ?? 'user';
// Top-level for maximum compatibility (Husaria reads these first).
api.accessToken.setCustomClaim('user_id', event.user.user_id);
api.accessToken.setCustomClaim('email', event.user.email);
api.accessToken.setCustomClaim('roles', roles);
api.accessToken.setCustomClaim('allowed_roles', roles);
api.accessToken.setCustomClaim('default_role', defaultRole);
// Optional: also emit the namespaced form for clients that read it.
api.accessToken.setCustomClaim(namespace, {
user_id: event.user.user_id,
roles,
allowed_roles: roles,
default_role: defaultRole
});
};
3. Configure Husaria #
# Auth0 publishes JWKS at https://<TENANT>.<REGION>.auth0.com/.well-known/jwks.json.
# Point Husaria at it directly — key rotations roll without a redeploy.
export HUSARIA_JWT_JWKS_URL="https://YOUR_TENANT.us.auth0.com/.well-known/jwks.json"
export HUSARIA_JWT_JWKS_CACHE_TTL=1h
export HUSARIA_JWT_ISSUER="https://YOUR_TENANT.us.auth0.com/"
export HUSARIA_JWT_AUDIENCE="https://api.example.com"
export HUSARIA_DEFAULT_ROLE=user
Notes:
- The trailing slash on
HUSARIA_JWT_ISSUERmatches what Auth0 puts in theissclaim — keep it. - Auth0's signing key rotates automatically; the JWKS cache picks up the new
kidon the next miss. No PEM to babysit.
Zitadel #
1. Create a project + application #
Zitadel dashboard: create a project, then within it create an API or web application. Choose JWT as the auth-method. The application's client ID is the value you'll use as audience.
2. Add a Pre-Userinfo / Pre-Access-Token action #
Zitadel exposes JWT shaping through actions (Console → Actions). Create one bound to the preaccesstoken trigger:
function preAccessToken(ctx, api) {
const roles = ctx.v1.user.grants
.filter(g => g.projectId === ctx.v1.user.resourceOwner)
.flatMap(g => g.roles || []);
const defaultRole = roles[0] || 'user';
// Husaria top-level claims
api.v1.claims.setClaim('user_id', ctx.v1.user.id);
api.v1.claims.setClaim('email', ctx.v1.user.email);
api.v1.claims.setClaim('roles', roles);
api.v1.claims.setClaim('allowed_roles', roles);
api.v1.claims.setClaim('default_role', defaultRole);
}
If you can't (or don't want to) modify top-level claims, Zitadel will happily nest under a custom claim — point your client at the https://husaria.app/jwt/claims namespace.
3. Configure Husaria #
# Pull the jwks_uri from the OIDC discovery doc once:
# https://<INSTANCE>.zitadel.cloud/.well-known/openid-configuration
export HUSARIA_JWT_JWKS_URL="https://YOUR_INSTANCE.zitadel.cloud/oauth/v2/keys"
export HUSARIA_JWT_JWKS_CACHE_TTL=1h
export HUSARIA_JWT_ISSUER="https://YOUR_INSTANCE.zitadel.cloud"
export HUSARIA_JWT_AUDIENCE="YOUR_CLIENT_ID"
export HUSARIA_DEFAULT_ROLE=user
Note: Zitadel's iss is without a trailing slash. Set it exactly as the OIDC discovery doc reports.
Keycloak #
1. Create a realm + client #
Keycloak admin console: create a realm (e.g. husaria), then within it a confidential client. The client ID is the audience identifier you'll point Husaria at.
2. Add a protocol mapper for husaria claims #
Inside the client: Mappers → Add → choose User Attribute or Script mapper depending on how you store roles.
The cleanest pattern is one mapper per husaria claim, all set to add to ID token + access token + userinfo:
| Mapper type | Token claim name | Source |
|---|---|---|
| User Property | user_id | id |
| User Property | email | email |
| User Realm Role | roles | (the user's realm roles as a String[]) |
| User Realm Role | allowed_roles | (same — Keycloak duplicates fine) |
| User Attribute | default_role | a single-value user attribute you set per user |
Tick Multivalued for the role mappers so the claim ships as a JSON array.
3. Configure Husaria #
# Keycloak's JWKS endpoint is /realms/<REALM>/protocol/openid-connect/certs.
export HUSARIA_JWT_JWKS_URL="https://keycloak.example.com/realms/husaria/protocol/openid-connect/certs"
export HUSARIA_JWT_JWKS_CACHE_TTL=1h
export HUSARIA_JWT_ISSUER="https://keycloak.example.com/realms/husaria"
export HUSARIA_JWT_AUDIENCE="YOUR_CLIENT_ID"
export HUSARIA_DEFAULT_ROLE=user
Notes:
- The
issclaim Keycloak mints is<base-url>/realms/<realm>— no trailing slash. - Audience in Keycloak is a bit awkward — the access token's
auddefaults toaccount. Add an Audience Resolve client scope (or a Hardcoded Audience mapper) to put your client ID inaud.
Local HS256 issuer #
For prototypes and tests, Husaria can mint its own tokens with HS256:
export HUSARIA_JWT_SECRET="<random 32+ byte string>"
export HUSARIA_JWT_ALGORITHM=HS256
export HUSARIA_JWT_EXPIRY=15m
export HUSARIA_JWT_REFRESH_EXPIRY=168h
The defaults are HUSARIA_JWT_EXPIRY=24h (access token) and HUSARIA_JWT_REFRESH_EXPIRY=168h / 7 days (refresh token); the example above shortens the access token to 15 minutes. Use the admin secret for service-to-service traffic and HS256 only for ephemeral dev tokens — HS256 keys live in env and can't be rotated without a deploy.
Validating it works #
Mint a token via your provider, send a request, expect a 200:
TOKEN="..." # paste the access token from your provider
curl -s http://localhost:8080/v1/graphql \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ __typename }"}'
If you get a 401, check Husaria's logs — JWT-validation errors are logged at WARN with the specific failure (bad signature, wrong issuer, expired, missing claims). If you get a 403, the JWT validated but X-Husaria-Role (or claims.default_role) failed the allowed_roles check — see the decision tree at the top of this page.