Skip to content
Hoody.com

Every request to a container service passes through the Hoody Proxy, which checks it against your permission configuration. You decide who can access which services, per group and per instance, at project or container level.

This builds on the proxy architecture and aliases.


This Foundation page explains permission concepts and configuration strategies. The complete endpoint documentation:

Project-level permissions:

Container-level permissions:

Container proxy hooks (MITM traffic interception):

Container proxy settings (root enable/default policy):


Access control happens in layers:

┌─────────────────────────────────────┐
│ Hoody API Authentication │ ← User login, API tokens
│ (api.hoody.com) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Hoody Proxy Permissions │ ← THIS PAGE
│ (Container access control) │
│ │
│ ├─ Project-Level Permissions │ ← Apply to all containers
│ └─ Container-Level Permissions │ ← Extra rules for specific containers
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Container Services │
│ (terminal, display, files, etc.) │
└─────────────────────────────────────┘

Two separate systems are involved:

  1. Hoody API auth - Access to platform management (create containers, configure firewall)
  2. Proxy permissions - Access to container services (execute commands, read files, view displays)

This page covers proxy permissions: how to control who can use your container’s terminal, files, displays, and other services.


Groups define authentication methods. Each group specifies how users prove their identity:

  • JWT - Token-based with secret verification and claims validation
  • Password - Username/password via HTTP Basic Auth
  • IP - Allow/deny based on client IP address or CIDR range
  • Token - Exact token validation from a configured header, cookie, or query parameter
  • Hoody Identity - Native Hoody identity-claim verification (hoody-identity)

For example:

{
"groups": {
"developers": {
"type": "ip",
"range": "203.0.113.0/24"
},
"customers": {
"type": "jwt",
"secret": "a-long-random-signing-key-with-32-plus-chars",
"algorithm": "HS256",
"sources": ["header:Authorization"]
},
"admin": {
"type": "password",
"username": "admin",
"password": "hashed-password",
"algorithm": "sha256",
"salt": "unique-salt"
}
}
}

Permissions map groups to programs, with per-instance control.

Each group gets specific access to container programs using:

  • true - Allow all instances
  • false - Deny all instances
  • number - Allow only this instance/port (e.g., 1 allows instance 1)
  • array - Allow specific instances/ports (e.g., [1, 2])
  • "8000-8100" - Allow a port range (network programs only)
  • "*" - Allow every instance (not accepted for http/https, which must name their ports)

Indexed programs (terminal, display, code, browser, …) accept all of the above. The programs files, exec, services, notifications, and logs accept only true/false.

http and https are not programs. They are the transport labels in a container hostname (<project>-<container>-http-8080), and their permission cell gates raw ports:

"http": [8080] // this port only
"http": [80, 8080] // these ports
"http": 8080 // shorthand for one port
"http": "8000-8100" // an inclusive range

These cells must name explicit ports. A bare true, false, or "*" is rejected with a 422, because an all-ports grant would also hand out every raw container port rather than only your application’s.

One http or https cell may open at most 16,384 ports, counted per cell rather than per container: a list of 16,385 entries or a range wider than 16,384 is rejected with the same 422. A near-total range amounts to the all-ports grant the rule above refuses, so name the ports your application serves. Ranges like "3000-9000" stay well inside the cap.

A port grant opens whatever is listening on that port inside your container.

Ports belonging to Hoody’s own always-on services are refused outright, so a grant can never reach them: the container daemon, file server, terminal, code server, browser, exec, the agent API and the shared display server are all off limits, and an attempt to name one is denied even inside a wide range.

What a grant does not do is inspect what else your container put on the port. If you started a display instance that happens to occupy the port you granted, the grant reaches that display, and a "display": false cell in the same group will not prevent it: the request arrives as a raw port, not as the display program. This matters only if you both deny a program and grant a port that program happens to be using; grant the ports your application serves and it does not arise.

ssh is not a permission gate. An ssh hostname carries no port and routes to the dedicated SSH proxy, so an ssh cell in this document has no effect; use the container’s SSH key configuration instead.

{
"permissions": {
"developers": {
"terminal": [1, 2], // Allow terminal instances 1 and 2 only
"files": true, // Allow all file service instances
"display": 1, // Allow only display instance 1
"http": [8080] // Allow port 8080 only
},
"customers": {
"http": [8080], // Allow only port 8080
"terminal": false, // Deny all terminal access
"files": false // Deny all file access
},
"admin": {
"terminal": true, // Full access to all terminals
"files": true, // Full access to files
"display": true, // Full access to all displays
"http": [8080, 8443] // Allow ports 8080 and 8443
}
}
}

The default policy decides what happens when no group matches:

{
"default": "deny" // or "allow"
}
  • “deny” - Block access if no group matches (secure by default)
  • “allow” - Permit access if no group matches (open by default)

Permissions can be set at two levels:

Project Level
├─ Applies to ALL containers in project
└─ Good for consistent team access
↓
Container Level
├─ Evaluated together with the project document
└─ Good for exceptions (public container in private project)

If both are configured, the two documents merge and container-level values take precedence wherever they overlap.


A new container has no proxy permissions configured. Its URLs are reachable by anyone who has them: the proxy applies no authentication and no group matching, and the two 24-character IDs in the hostname are the credential.

The URL contains:

https://67e89abc123def456789abcd-890abcdef12345678901cdef-terminal-1.node-us.containers.hoody.com
└────────24-char hex────┘ └────────24-char hex────┘

The project and container IDs give 2^96 × 2^96 = 2^192 possible combinations, which makes the pair practically unguessable. Sharing the URL grants access, and so does any other way the URL travels: pasted into Slack, captured in a screenshot, logged by a referer header, or cached by a browser extension, it lets the holder hit the container directly. Unguessability prevents blind discovery; it does not contain leaks or replace access control.

This default is what makes instant collaboration work: sharing the URL is the whole setup, nothing needs configuring, and anyone who has the link can join.

When you need real access control, set proxy permissions before sharing anything sensitive. Do not rely on URL secrecy, and never paste container URLs into public channels, public dashboards, or untrusted third-party tools.


To apply authentication to every container in a project:

Terminal window
# Set project-level proxy permissions (IP-restricted team access).
# To replace the whole document in one write, use `hoody projects proxy permissions set
# --project $PROJECT_ID --groups @groups.json --permissions @permissions.json --default deny
# --if-match file:v<N>`.
# This example builds it one field at a time. Each write bumps file_version, so
# re-read the If-Match ETag before every command with
# `hoody projects proxy permissions get --project $PROJECT_ID -o json`.
hoody projects proxy groups ip set --project $PROJECT_ID \
--group-name team --range 203.0.113.0/24 --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name team --program terminal --access '[1,2]' --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name team --program files --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name team --program display --access 1 --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name team --program http --access '[8080]' --if-match file:v<N>
hoody projects proxy default set --project $PROJECT_ID --default deny --if-match file:v<N>
PATCH Set project-level proxy permissions
/api/v1/projects/{project_id}/proxy/permissions
Click "Run" to execute the request

Every container in this project now:

  • requires a client IP in the 203.0.113.0/24 range
  • grants that group access to terminal, files, display, and http
  • denies all other access

To set container-level permissions for a specific container, evaluated together with the project document:

Terminal window
# Override permissions for a specific container (public HTTP only).
# To replace the whole document in one write, use `hoody containers proxy permissions set
# -c $CONTAINER_ID --project $PROJECT_ID --groups @groups.json --permissions @permissions.json
# --default deny --if-match file:v<N>`.
# This example builds it one field at a time, re-reading the If-Match ETag before
# every command with `hoody containers proxy permissions get -c $CONTAINER_ID -o json`.
hoody containers proxy groups ip set -c $CONTAINER_ID \
--group-name public --range 0.0.0.0/0 --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name public --program http --access '[8080]' --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name public --program terminal --access false --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name public --program files --access false --if-match file:v<N>
hoody containers proxy default set -c $CONTAINER_ID --default deny --if-match file:v<N>
PATCH Set container-level proxy permissions (evaluated with project)
/api/v1/containers/{container_id}/proxy/permissions
Click "Run" to execute the request

This container now:

  • accepts requests from any IP (public access)
  • allows HTTP services only
  • denies terminal and files for the public group
  • overrides the project document only where entries overlap; project groups this document does not name stay in force

A typical use: a public demo container in an otherwise private project.


A group defines how a client authenticates.

A jwt group validates JSON Web Tokens:

{
"groups": {
"authenticated_users": {
"type": "jwt",
"secret": "a-long-random-signing-key-with-32-plus-chars",
"algorithm": "HS256",
"sources": ["header:Authorization", "cookie:__Host-auth_token"],
"claims": {
"iss": "mycompany.com",
"aud": "production-api"
}
}
}
}

Parameters:

  • secret - JWT signing key (required), up to 8192 characters. An HS256 secret is checked when you write it: at least 32 characters, no PEM key material (use an RS256/ES256 group for a public key), and no common word such as secret, password, 123456, test, dev, or default anywhere in it. For RS256/ES256, use the PEM-encoded SPKI public key. A secret already stored under the same algorithm skips this check, so an unrelated edit to an old group is not blocked by it; anything new or changed is always validated.
  • algorithm - HS256, RS256, or ES256 (required)
  • sources - Where to look for the token (required). Each entry must match header:Name or cookie:Name; JWTs can only be read from a header or a cookie. By default, a token from any listed source counts: an invalid token in one source does not hide a valid one in another, and repeated copies of a header or cookie are considered, up to the proxy’s verification limits. A JWT group without sources reads only the Authorization header.
  • claims - Required JWT claims to validate (optional)
  • header_authoritative - true or false (optional). When true and a configured header source is present in the request, even empty, only header sources decide. When false or omitted, a valid token in any configured source counts.

Token source examples:

  • header:Authorization - Bearer token in the Authorization header
  • cookie:__Host-session - JWT in a session cookie

Use this when your application issues JWTs to users and Hoody validates them at the proxy.

How the proxy reads cookie and header sources:

  • Name signed (RS256/ES256) cookie sources with the __Host- prefix; other cookies with the same name cannot then be added by other addresses.
  • If you configure a cookie as a credential source, do not also use that cookie name for your application’s own data: when a request carries more copies than the proxy checks, that name is removed before forwarding.
  • Each token or JWT definition verifies up to 16 signed (RS256/ES256) or 256 other candidates per request, shared across its sources, and a request verifies at most 128 signed and 4096 other candidates in all. When either limit leaves a group’s membership undecided and the group affects the decision, the request is refused with 403 CREDENTIAL_UNRESOLVED. Keep a container’s permissions within 8 signed and 16 other token or JWT definitions.
  • Because a valid token in any source counts, a request can match a group it did not match before. If no matched group allows the program, an explicit non-allowing rule now refuses it with 403; a newly matched allowing group can change which hooks apply.

If your login cookie was set for a parent domain of your Hoody address, do this before the cookie narrowing reaches your address: stop setting it for the parent domain (in server responses and in page scripts), move the permission group to a __Host- cookie name or invalidate the old token, then check that a request with the old cookie is refused. Otherwise an old copy keeps working after logout until it expires.

A hoody-identity group gates a service on “a valid Hoody user”. The proxy verifies the ED25519 identity claim Hoody mints (via POST /api/v1/users/auth/identity-claim), rejects everything else, and injects verified identity headers your app can trust:

{
"groups": {
"hoody_users": {
"type": "hoody-identity",
"audience": "myapp.example.com",
"allow_types": ["user"],
"users": ["507f1f77bcf86cd799439011"],
"max_age_seconds": 86400,
"expose_type": false
}
}
}

Parameters:

  • audience - Required. The claim’s aud must equal this exactly. Clients mint a bound claim with POST /api/v1/users/auth/identity-claim { "audience": "myapp.example.com" }. Unbound (login-issued) claims are always rejected; an audience gate can never be satisfied by a generic 30-day login claim.
  • sources - Optional header sources within the reserved namespace (default ["header:X-Hoody-Identity-Claim"]). Clients send the claim as <kid>.<payload_b64>.<signature_hex>.
  • allow_types - Optional. The identity types to accept; ["user"] accepts every Hoody user account.
  • users - Optional allowlist of user IDs (up to 256); enforces “my users only” at the proxy.
  • max_age_seconds - Optional freshness gate on the claim’s iat (min 300).
  • expose_type - Also inject X-Hoody-Identity-Type, the claim’s identity type. Off by default; it is not a role in your app, so do not use it for authorization decisions inside the app.

Your app receives verified X-Hoody-Identity-Sub (user ID) and X-Hoody-Identity-Username headers, injected by the edge after it unconditionally strips any client-supplied X-Hoody-Identity-* headers. The values are trustworthy on every reverse-proxy request, and the claim credential itself never reaches your app. Hoody manages the verifying keys, so you need none: group config never carries keys.

A group alone is not a gate. The file must also set explicit service cells and "default": "deny", or unauthenticated traffic falls through to the default policy:

{
"groups": { "hoody_users": { "type": "hoody-identity", "audience": "myapp.example.com" } },
"permissions": { "hoody_users": { "run": true } },
"default": "deny"
}

After activating, verify that anonymous access is denied: a curl without the header must get 403. Once a gate file has been active, a missing or undistributed file fails closed (deny), never open.

In v1 the gate covers programmatic clients: CLIs, SDKs, native apps, and server-to-server calls. Browsers cannot attach custom headers on navigation, so use a proxy hook for browser apps. It applies on the reverse-proxy path only (not the forward-proxy/CONNECT products) and gates WebSocket handshakes; established sockets outlive claim expiry, so terminate server-side if you need live cutoff.

A password group uses HTTP Basic Auth:

{
"groups": {
"admin_access": {
"type": "password",
"username": "admin",
"password": "hashed-password-here",
"algorithm": "sha256",
"salt": "unique-salt-value"
}
}
}

Parameters:

  • username - Exact username match required
  • password - Plain or hashed password
  • algorithm - Hashing algorithm (sha256)
  • salt - Salt for password hashing

Browsers show their built-in Basic Auth prompt automatically.

Basic Auth is an old protocol, and that is its value here: it is portable. It needs no JWT library or token management, every browser has a native prompt, and any HTTP client can send it with nothing but a username and password. Credentials are simple enough for a person to remember and type, and they can be embedded in a URL (https://user:pass@domain.com works in most contexts). There is no token expiration, refresh flow, or claims validation to manage, and it keeps working when an external OAuth provider is down.

Common uses:

  • Simple admin access
  • Quick demos and prototypes
  • Temporary contractor access
  • Emergency access paths
  • Internal tools where JWT overhead isn’t worth it
  • Any scenario where portability matters more than sophistication

An ip group allows or denies by client IP address:

{
"groups": {
"office_network": {
"type": "ip",
"range": "203.0.113.0/24"
},
"vpn_users": {
"type": "ip",
"range": "198.51.100.0/24"
}
}
}

Parameters:

  • range - IPv4 CIDR notation (e.g., 203.0.113.50/32 for a single IP)

Use this to restrict access to an office network, a VPN, or known IPs. A group on its own admits nobody and excludes nobody: it has to be granted the service, and the effective default has to be deny, or callers that match no group still get in. Container and project documents merge, and the container’s default wins when it sets one, so check both. Development team access is the complete version. For the other two layers that can filter by client IP, and when each one applies, see allow only certain client IPs.

The proxy sees real client IPs (not proxy IPs) thanks to Hoody’s netfilter hooks, so IP rules match the actual client.

A token group validates an exact token value from a configured header, cookie, or query parameter:

{
"groups": {
"api_partners": {
"type": "token",
"value": "token-abc-123",
"header": "X-Api-Token"
}
}
}

A token group carries a single value plus exactly one of header, cookie, or param specifying where the token is read from. For a cookie token, follow the cookie advice under JWT authentication: a __Host- name, set by the address it protects.

Use this to distribute tokens to API consumers, partners, and integrations.

Authentication without headers (hoody-curl)

Section titled “Authentication without headers (hoody-curl)”

Some environments cannot send custom headers: browser bookmarks (just URLs), QR codes (GET only), email links (no header control), and restricted platforms (iOS Shortcuts, some automation tools). hoody-curl works around this by transforming an authenticated request into a plain GET URL.

How it works:

Terminal window
# Requires ability to send Authorization header
curl "https://PROJECT_ID-CONTAINER_ID-terminal-1.node-us.containers.hoody.com/api/v1/terminal/execute" \
-H "Authorization: Bearer jwt-token-here" \
-H "Content-Type: application/json" \
-d '{"command": "ls -la"}'
# Can't do this from browser bookmark
# Can't do this from QR code
# Can't embed in simple URL

For example, a bookmark that triggers a deployment:

// Create a bookmark URL that deploys your app
const curlService = "https://PROJECT_ID-CONTAINER_ID-curl-1.node-us.containers.hoody.com";
const targetService = "https://PROJECT_ID-CONTAINER_ID-exec-1.node-us.containers.hoody.com/api/deploy";
const authToken = "your-jwt-token";
const bookmarkUrl = `${curlService}/api/v1/curl/request?` + new URLSearchParams({
url: targetService,
method: 'POST',
header: `Authorization: Bearer ${authToken}`,
json: JSON.stringify({ environment: 'production' })
});
// Save as bookmark: "Deploy Production"
// Clicking the bookmark triggers the deployment
// without a terminal or a curl command

Use cases:

  • Emergency deployments - A bookmark that deploys
  • Mobile access - A QR code that triggers a workflow
  • Email notifications - “Click here to approve” links
  • Restricted automation - iOS Shortcuts, Zapier webhooks
  • Simple sharing - Send a URL instead of a curl command

See Hoody cURL for complete documentation on wrapping HTTP requests.


Every program takes an access rule, and most of them can name individual instances instead of the whole program. Five do not: files, services, notifications, exec and logs hold a boolean and nothing else, and a number, an array or a "*" in one of those cells is rejected with a 422.

A permission cell holds a boolean, a number, an array, a port-range string, or a wildcard:

{
"permissions": {
"developers": {
"terminal": true, // Allow ALL terminal instances
"files": false // Deny ALL file service instances
}
}
}

Use when: Simple all-or-nothing access

A permission cell is keyed by a program name from the container program catalog or by a transport label. The catalog ships 24 program names and 3 transport labels; a name you write that is in neither list is rejected.

ProgramAlso accepted asWhat the cell gates
terminalt, tty, ttydHoody Terminal instances
displaydHoody Display instances: the browser viewer and the HTTP API for screenshots, mouse, keyboard, window, and clipboard control
desktopThe terminal kit in desktop mode
d-tcpdtDirect TCP display services
filesfHoody Files
execeHoody Exec script execution
browserbRemote browser instances
cdpThe Chrome DevTools Protocol endpoint for a browser instance
codeHoody Code
agentHoody Agent
sqliteThe SQLite database service
notesnoteCollaborative notes and documents
notificationsnotification, nThe notification server
watchwFile-change watch and event replay
cronCrontab management
curlThe cURL HTTP client service
runThe Hoody Run command resolver
pipeHoody Pipe streaming
tunnelThe reverse-tunnel gateway
egressThe container’s outbound egress proxy
workspacesworkspace, osHoody Workspaces
daemonThe container daemon and its background programs
logslog, lThe proxy-side request and response logs for the container
botOperating Hoody from chat platforms

An alias is the same cell as the name it stands for, not a second one: "t": [1] and "terminal": [1] are the same grant, and writing both in one group is writing the same key twice. Counting every alias, the validator accepts 43 distinct strings, which is why a name absent from this table can still be valid.

The three transport labels are separate:

  • http and https gate raw ports and must name them, as described above
  • ssh is accepted by the schema but inert: ssh hostnames route to the dedicated SSH proxy (see the note near the top of this page)

services, for container service endpoints, is accepted as a boolean cell alongside the catalog names.

Two things behave in ways the table does not show. A display grant has no view-only level, though the browser viewer accepts ?readonly=true, which blocks its own mouse and keyboard input. And five cells are boolean only — files, services, notifications, exec and logs — so "exec": [1] or "notifications": [1, 2] is refused with a 422 even though an array is the ordinary shape everywhere else on this page. logs goes one step further: a group’s catch-all "*" cell never covers it, so reading logs takes an own "logs": true and nothing else will do.

A container running five terminal instances can give each team its own:

{
"permissions": {
"frontend_team": {
"terminal": [1, 2], // Frontend devs get terminals 1 and 2
"display": 1,
"http": [8080]
},
"backend_team": {
"terminal": [3, 4], // Backend devs get terminals 3 and 4
"display": 2,
"http": [8080]
},
"ops_team": {
"terminal": true, // Ops gets ALL terminals
"display": true, // ALL displays
"http": [8080]
}
}
}

The frontend and backend teams reach only their assigned terminal and display instances; ops reaches all terminal and display instances, and all three groups share HTTP port 8080.


An internal team with broad access, restricted to the office IP range:

Terminal window
# Configure IP-restricted developer access for entire project.
# To replace the whole document in one write, use `hoody projects proxy permissions set
# --project $PROJECT_ID --groups @groups.json --permissions @permissions.json --default deny
# --if-match file:v<N>`.
# This example builds it one field at a time, re-reading the If-Match ETag before
# every command with `hoody projects proxy permissions get --project $PROJECT_ID -o json`.
hoody projects proxy groups ip set --project $PROJECT_ID \
--group-name developers --range 203.0.113.0/24 --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program terminal --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program display --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program files --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program http --access '[8080]' --if-match file:v<N>
hoody projects proxy default set --project $PROJECT_ID --default deny --if-match file:v<N>
PATCH Configure IP-restricted developer access
/api/v1/projects/{project_id}/proxy/permissions
Click "Run" to execute the request

With this configuration:

  • developers from the office network (203.0.113.0/24) get terminal, display, files, and HTTP port 8080 access
  • everyone else is denied
  • the rules apply to every container in the project

A container-level override that opens the HTTP API while keeping the admin services behind a password:

Terminal window
# Public API (JWT for customers) + private admin group (password).
# To replace the whole document in one write, use `hoody containers proxy permissions set
# -c $CONTAINER_ID --project $PROJECT_ID --groups @groups.json --permissions @permissions.json
# --default deny --if-match file:v<N>`.
# This example builds it one field at a time, re-reading the If-Match ETag before
# every command with `hoody containers proxy permissions get -c $CONTAINER_ID -o json`.
hoody containers proxy groups jwt set -c $CONTAINER_ID \
--group-name customers --secret 'a-long-random-signing-key-with-32-plus-chars' \
--algorithm HS256 --sources header:Authorization --if-match file:v<N>
hoody containers proxy groups password set -c $CONTAINER_ID \
--group-name admin --auth-username admin --auth-password 'hashed-admin-password' \
--salt 'unique-salt' --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name customers --program http --access '[8080]' --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name admin --program terminal --access true --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name admin --program display --access true --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name admin --program files --access true --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name admin --program http --access '[8080]' --if-match file:v<N>
hoody containers proxy default set -c $CONTAINER_ID --default deny --if-match file:v<N>
PATCH Configure public API with private admin access
/api/v1/containers/{container_id}/proxy/permissions
Click "Run" to execute the request

With this configuration:

  • customers with a valid JWT reach the HTTP API only
  • the admin group authenticates with a password and reaches terminal, display, and all files
  • everyone else is denied

Three teams on one project document, each with a different level of access:

Terminal window
# Multi-tier: ops (terminal/display/files/port 8080), developers (terminal/files/port 8080), readonly (port 8080 only).
# To replace the whole document in one write, use `hoody projects proxy permissions set
# --project $PROJECT_ID --groups @groups.json --permissions @permissions.json --default deny
# --if-match file:v<N>`.
# This example builds it one field at a time, re-reading the If-Match ETag before
# every command with `hoody projects proxy permissions get --project $PROJECT_ID -o json`.
hoody projects proxy groups ip set --project $PROJECT_ID \
--group-name ops_team --range 203.0.113.0/24 --if-match file:v<N>
hoody projects proxy groups ip set --project $PROJECT_ID \
--group-name developers --range 198.51.100.0/24 --if-match file:v<N>
hoody projects proxy groups password set --project $PROJECT_ID \
--group-name readonly_users --auth-username viewer --auth-password 'hashed-pass' \
--salt 'salt' --if-match file:v<N>
# ops_team: terminal, display, files, http
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name ops_team --program terminal --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name ops_team --program display --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name ops_team --program files --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name ops_team --program http --access '[8080]' --if-match file:v<N>
# developers: terminal, files, http
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program terminal --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program files --access true --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name developers --program http --access '[8080]' --if-match file:v<N>
# readonly_users: http only
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name readonly_users --program http --access '[8080]' --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name readonly_users --program terminal --access false --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name readonly_users --program display --access false --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name readonly_users --program files --access false --if-match file:v<N>
hoody projects proxy default set --project $PROJECT_ID --default deny --if-match file:v<N>
PATCH Configure multi-tier team access
/api/v1/projects/{project_id}/proxy/permissions
Click "Run" to execute the request

The three groups resolve to:

  • Ops team (203.0.113.0/24): terminal, display, files, and HTTP port 8080
  • Developers (198.51.100.0/24): terminal, files, and HTTP port 8080; no display
  • Read-only users (password auth): HTTP only

When both documents exist, the proxy resolves a request like this:

Request arrives at container service URL
↓
Check: which permission documents exist?
↓
BOTH → Merge project and container permissions
(container values win conflicts)
ONE → Use the one that exists
NEITHER → Default open (anyone with URL can access)

For example, a project restricted to the office IP, with one container opened for public HTTP:

Terminal window
# Project: Restrict all containers to office IP
PATCH /api/v1/projects/{id}/proxy/permissions
If-Match: file:v<N>
{
"project": "{id}",
"groups": { "office": { "type": "ip", "range": "203.0.113.0/24" } },
"permissions": { "office": { "terminal": true, "http": [8080] } },
"default": "deny"
}
# Container: Override one container for public access
PATCH /api/v1/containers/{id}/proxy/permissions
If-Match: file:v<N>
{
"project": "{project-id}",
"container": "{id}",
"groups": { "public": { "type": "ip", "range": "0.0.0.0/0" } },
"permissions": { "public": { "http": [8080] } },
"default": "deny"
}

With both documents in place:

  • most containers: office-only access (terminal and HTTP)
  • public container: anyone can reach HTTP
  • public container: its own document lists only the public group, and the office grant is not repeated there. Both documents govern a container, so read the project file and the container file together before assuming what a given request resolves to.

Terminal window
# Get current config (read the file_version ETag for If-Match)
hoody projects proxy permissions get --project $PROJECT_ID
# Replace the whole project document in one write
hoody projects proxy permissions set --project $PROJECT_ID \
--groups @groups.json --permissions @permissions.json --default deny --if-match file:v<N>
# Or set it one field at a time
hoody projects proxy groups ip set --project $PROJECT_ID \
--group-name <name> --range 203.0.113.0/24 --if-match file:v<N>
hoody projects proxy groups permissions set --project $PROJECT_ID \
--group-name <name> --program http --access '[8080]' --if-match file:v<N>
# Delete all permissions (revert to open)
hoody projects proxy permissions delete --project $PROJECT_ID --if-match file:v<N>
# Update default policy only
hoody projects proxy default set --project $PROJECT_ID --if-match file:v<N> --default deny
# Enable the proxy again (`hoody projects proxy disable` switches it off).
hoody projects proxy enable --project $PROJECT_ID --if-match file:v<N>
Terminal window
# Get container config (read the file_version ETag for If-Match)
hoody containers proxy permissions get --container $CONTAINER_ID
# Replace the whole container document in one write (evaluated together with the project document)
hoody containers proxy permissions set -c $CONTAINER_ID --project $PROJECT_ID \
--groups @groups.json --permissions @permissions.json --default deny --if-match file:v<N>
# Or set it one field at a time
hoody containers proxy groups ip set -c $CONTAINER_ID \
--group-name <name> --range 203.0.113.0/24 --if-match file:v<N>
hoody containers proxy groups permissions set -c $CONTAINER_ID \
--group-name <name> --program http --access '[8080]' --if-match file:v<N>
# Delete container permissions (revert to project-level)
hoody containers proxy permissions delete --container $CONTAINER_ID --if-match file:v<N>
# Update default policy
hoody containers proxy default set --container $CONTAINER_ID --if-match file:v<N> --default allow

Add, update, or remove a single group without replacing the whole document:

Terminal window
# Add JWT group to project
hoody projects proxy groups jwt set --project $PROJECT_ID --group-name api-users \
--if-match file:v<N> \
--secret "a-long-random-signing-key-with-32-plus-chars" --algorithm HS256 --sources "header:Authorization"
# Add IP group to project
hoody projects proxy groups ip set --project $PROJECT_ID --group-name office \
--if-match file:v<N> --range "198.51.100.0/24"
# Remove group entirely
hoody projects proxy groups delete --project $PROJECT_ID --group-name office \
--if-match file:v<N>

Set or remove a group’s program permissions:

Terminal window
# Set permissions for a group (boolean, all instances)
hoody projects proxy groups permissions set --project $PROJECT_ID --group-name developers \
--if-match file:v<N> --program terminal --access true
# Set permissions (specific instance)
hoody projects proxy groups permissions set --project $PROJECT_ID --group-name developers \
--if-match file:v<N> --program display --access 1
# Set permissions (multiple instances)
hoody projects proxy groups permissions set --project $PROJECT_ID --group-name developers \
--if-match file:v<N> --program terminal --access "[1,2,3]"
# Remove all permissions for a group
hoody projects proxy groups permissions clear --project $PROJECT_ID --group-name developers \
--if-match file:v<N>
# Remove specific program permission
hoody projects proxy groups permissions delete --project $PROJECT_ID --group-name developers \
--if-match file:v<N> --program terminal

Progressive lockdown from dev to production

Section titled “Progressive lockdown from dev to production”

Development phase. No permissions are configured, so anyone holding a URL can reach the container and the team shares URLs directly.

Staging phase. Restrict the whole project to the office IP range:

Terminal window
PATCH /api/v1/projects/{id}/proxy/permissions
If-Match: file:v<N>
{
"project": "{id}",
"groups": {
"team": { "type": "ip", "range": "203.0.113.0/24" }
},
"permissions": {
"team": { "terminal": true, "http": [8080], "display": true }
},
"default": "deny"
}

Production phase (add customer JWT access on the production container):

Terminal window
# Override production container only
PATCH /api/v1/containers/{prod_id}/proxy/permissions
If-Match: file:v<N>
{
"project": "{project-id}",
"container": "{prod_id}",
"groups": {
"customers": {
"type": "jwt",
"secret": "a-long-random-signing-key-with-32-plus-chars",
"algorithm": "HS256",
"sources": ["header:Authorization"]
}
},
"permissions": {
"customers": { "http": [8080] }
},
"default": "deny"
}

Result:

  • Dev containers: Open (cryptographic URLs)
  • Staging containers: Office IP only
  • Production container: the project team group (office IP range) still grants terminal, display, and HTTP port 8080, while the container customers group also grants JWT-authenticated access to HTTP port 8080

Give support team temporary terminal access:

Terminal window
# Add support group to specific container
PATCH /api/v1/containers/{id}/proxy/permissions/groups/support/password
If-Match: file:v<N>
{
"username": "support",
"password": "temporary-password-hash",
"algorithm": "sha256",
"salt": "salt"
}
# Grant specific access to support team
PATCH /api/v1/containers/{id}/proxy/permissions/permissions/support
If-Match: file:v<N>
{
"program": "terminal",
"access": 1 // Only terminal 1
}
PATCH /api/v1/containers/{id}/proxy/permissions/permissions/support
If-Match: file:v<N>
{
"program": "display",
"access": 1 // Only display 1
}
PATCH /api/v1/containers/{id}/proxy/permissions/permissions/support
If-Match: file:v<N>
{
"program": "files",
"access": true // All file instances
}

Support can:

  • Access terminal instance 1 only
  • Open display 1, with full mouse, keyboard, and clipboard control
  • Use all file service instances

Instance control prevents accidental access to other terminals used by your team.

After support session: Delete the support group or disable it.

Different token tiers for partners:

Terminal window
# Mutating /proxy/permissions requires an If-Match: file:v<N> header (ETag from a prior GET).
PATCH /api/v1/containers/{api_id}/proxy/permissions
If-Match: file:v<N>
{
"project": "{project-id}",
"container": "{api_id}",
"groups": {
"tier1_partners": {
"type": "token",
"value": "partner-abc-tier1",
"header": "X-Api-Token"
},
"tier2_partners": {
"type": "token",
"value": "partner-def-tier2",
"header": "X-Api-Token"
}
},
"permissions": {
"tier1_partners": {
"http": [8080], // Only HTTP on port 8080
"files": true // All file instances
},
"tier2_partners": {
"http": [80, 3000], // Only HTTP on ports 80 and 3000
"files": false // No file access (files is boolean-only)
}
},
"default": "deny"
}

After setting permissions, test each group:

Terminal window
# Get current config
hoody projects proxy permissions get --project $PROJECT_ID
# Test access from your machine (for IP groups)
curl "https://PROJECT_ID-CONTAINER_ID-terminal-1.node-us.containers.hoody.com"
# Test with Basic Auth (for password groups)
curl -u "username:password" \
"https://PROJECT_ID-CONTAINER_ID-terminal-1.node-us.containers.hoody.com"
# Test with JWT (for JWT groups)
curl -H "Authorization: Bearer eyJhbG..." \
"https://PROJECT_ID-CONTAINER_ID-http-8080.node-us.containers.hoody.com/api/endpoint"

If access is denied unexpectedly:

  1. Check group matches:

    Terminal window
    GET /api/v1/projects/{id}/proxy/permissions
    # Verify group exists and credentials/IP match
  2. Check program permissions:

    // Ensure the program is allowed for the group
    "permissions": {
    "yourgroup": {
    "terminal": true // Must be explicitly true
    }
    }
  3. Check default policy:

    "default": "deny" // If no group matches, deny
  4. Check container override:

    Terminal window
    GET /api/v1/containers/{id}/proxy/permissions
    # Container config might override project
  5. Check proxy enabled:

    "enable_proxy": true // Must be true

{
"default": "deny"
}

Why: Explicit allow is more secure than implicit allow. If you add new programs later, they’re denied by default until you explicitly permit them.

Grant minimum necessary access:

{
"permissions": {
"api_users": {
"http": [80], // Only HTTP on port 80 (public API)
"terminal": false, // No terminal access
"files": false, // No file access
"display": false, // No display access
"exec": false // No exec access
}
}
}

Combine multiple authentication methods:

{
"groups": {
"secure_access": {
"type": "ip",
"range": "203.0.113.0/24" // Matches users from the office network
},
"with_jwt": {
"type": "jwt",
"secret": "a-long-random-signing-key-with-32-plus-chars",
"algorithm": "HS256",
"sources": ["header:Authorization"] // Matches users with a valid JWT
}
}
}

Any matching group grants access. All groups are checked in order. A user from the office network matches secure_access; a user with a valid JWT matches with_jwt. Either path works independently. If you need a single group that requires both IP and a JWT simultaneously, that is not supported natively by the permissions model; use a proxy hook for that logic.

Terminal window
# List all project permissions
curl "https://api.hoody.com/api/v1/projects" \
-H "Authorization: Bearer $HOODY_TOKEN"
# For each project, check permissions
curl "https://api.hoody.com/api/v1/projects/{id}/proxy/permissions" \
-H "Authorization: Bearer $HOODY_TOKEN"
# Look for:
# - Overly broad IP ranges (0.0.0.0/0)
# - Expired access that should be removed
# - Groups no longer needed

Completely disable proxy for a project/container:

Disabling the project does not cover a container whose own document sets enable_proxy: true; disable that container too.

Terminal window
# Disable at project level
hoody projects proxy disable --project $PROJECT_ID --if-match file:v<N>
# Disable at container level
hoody containers proxy disable --container $CONTAINER_ID --if-match file:v<N>
# Re-enable
hoody containers proxy enable --container $CONTAINER_ID --if-match file:v<N>

When disabled:

  • enable_proxy: false is a kill-switch, not a bypass. New requests that reach the proxy’s permission layer are denied with 403, because the proxy answers itself instead of forwarding to your service
  • The check runs before authentication groups, permission rules, and the default policy, so no configured rule can re-open access while the switch is off
  • The container keeps running, and access that never traverses the proxy (for example SSH) is unaffected. Established WebSocket and CONNECT tunnels are not torn down, because the gate applies to new connections. A flip can take a few seconds to take effect
  • Re-enable to restore group/permission enforcement

Scope, and read this before disabling at project level. The container-level setting wins over the project-level one in both directions: a container document with enable_proxy: true stays reachable even while its project is set to false. This is not a corner case: the API writes enable_proxy: true explicitly whenever a container permissions document is saved, so most containers carry an explicit true. To reliably disable a specific container, set it at container level.


{
"project": "string (required, project ID)",
"container": "string (required for container-level; omit at project level)",
"groups": {
"{groupName}": {
"type": "jwt" | "password" | "ip" | "token" | "hoody-identity",
// ... type-specific fields
}
},
"permissions": {
"{groupName}": {
// Network programs (terminal, display, http): boolean, a port/instance
// number, an array of numbers, a port-range string like "8000-8100", or "*"
"terminal": true | false | number | [number] | "8000-8100" | "*",
"display": true | false | number | [number] | "8000-8100" | "*",
// http and https must NAME their ports: no true, false, or "*",
// and at most 16384 ports per cell
"http": [8080] | 8080 | "8000-8100",
// Boolean-only programs (files, exec, services, notifications, logs)
"files": true | false,
"exec": true | false,
"services": true | false,
"notifications": true | false,
"logs": true | false
}
},
"default": "allow" | "deny",
"enable_proxy": true | false // optional, defaults to true
}

enable_proxy is an optional field of the permissions document body (it defaults to true and is persisted in the document’s settings alongside default). To flip it without rewriting the whole document, use the dedicated proxy state/settings endpoint instead (PATCH .../proxy/permissions/state or PATCH .../proxy/settings).

JWT:

{
"type": "jwt",
"secret": "string (required)",
"algorithm": "HS256 | RS256 | ES256 (required)",
"sources": ["string"] (required, e.g., ["header:Authorization"]),
"claims": {} (optional, required JWT claims)
}

Password:

{
"type": "password",
"username": "string (required)",
"password": "string (required, plain or hashed)",
"algorithm": "sha256 (required in a full permissions document; the dedicated password-group endpoint defaults it to sha256)",
"salt": "string (required)"
}

IP:

{
"type": "ip",
"range": "string (required, IPv4 CIDR)"
}

Token:

{
"type": "token",
"value": "string (required, the token to match)",
"header": "string" | "cookie": "string" | "param": "string"
// exactly one of header | cookie | param specifying where the token is read from
}

Hoody Identity:

{
"type": "hoody-identity",
"audience": "string (required, the claim's aud must equal this exactly)",
"sources": ["string"] (optional, defaults to ["header:X-Hoody-Identity-Claim"]),
"allow_types": ["user"] (optional, the identity types to accept),
"users": ["string"] (optional, allowlist of up to 256 user IDs),
"max_age_seconds": 86400 (optional, freshness gate on the claim's iat, minimum 300),
"expose_type": false (optional, also inject X-Hoody-Identity-Type)
}

Details and caveats: Hoody Identity Authentication.


Can I use multiple authentication methods for the same group?

Section titled “Can I use multiple authentication methods for the same group?”

No. Each group uses exactly one authentication type (JWT, password, IP, token, or hoody-identity). However, you can create multiple groups with different auth methods and all will be checked. If any group matches, the user gets access with that group’s permissions.

Do container-level permissions merge with project-level permissions?

Section titled “Do container-level permissions merge with project-level permissions?”

Yes. Container-level permissions merge with project-level permissions. Project entries remain effective unless an overlapping container group or permission overrides them; the container default takes precedence when present.

What happens if no permissions are configured at all?

Section titled “What happens if no permissions are configured at all?”

The container is open by default - anyone with the URL can access all services. This is intentional for rapid development and instant collaboration. The cryptographic URL (2^192 combinations) provides security through obscurity.

Can I restrict access to specific HTTP ports?

Section titled “Can I restrict access to specific HTTP ports?”

Yes. Use instance numbers for the http program. For example, "http": [80, 3000] allows only ports 80 and 3000. Each port is treated as an instance.

How do I temporarily disable access to a container?

Section titled “How do I temporarily disable access to a container?”

Use the proxy state endpoint:

Terminal window
PATCH /api/v1/containers/{id}/proxy/permissions/state
If-Match: file:v<N>
{ "enable_proxy": false }

New requests to that container’s proxied service URLs then return 403 until you re-enable it. This is a hard cut, not a bypass: the switch is evaluated before groups, rules, and the default policy. The container keeps running, and access that does not traverse the proxy (for example SSH) still works. Set it at container level, because a project-level false does not override a container whose own document says true.

Can IP authentication work with dynamic IPs?

Section titled “Can IP authentication work with dynamic IPs?”

IP auth requires static IPs or CIDR ranges. For dynamic IPs, use JWT or token authentication instead, or combine IP with a VPN that provides static exit IPs.

What’s the difference between “default”: “allow” and no permissions?

Section titled “What’s the difference between “default”: “allow” and no permissions?”
  • No permissions configured: Open by default, no auth required
  • “default”: “allow”: If groups exist but none match, still allow access
  • “default”: “deny”: If groups exist but none match, deny access

Use "default": "deny" for security when you have authentication groups.

Not through the proxy permissions system directly. For access logging, use:

  • Container firewall logs for connection attempts
  • Service-level logging (terminal, exec, etc. all support logging)
  • MITM via hoody-exec to log all HTTP traffic
  1. Add new group with new secret/tokens
  2. Update client applications to use new credentials
  3. Verify new group works
  4. Delete old group: DELETE /api/v1/projects/{id}/proxy/permissions/groups/{oldGroupName}

Can password authentication use bcrypt or argon2?

Section titled “Can password authentication use bcrypt or argon2?”

Currently only SHA256 is supported for password hashing. For stronger auth, use JWT with a proper authentication service.


The proxy now enforces:

  1. Authentication configured - Groups define who can access
  2. Permissions set - Programs define what they can do
  3. Default policy chosen - default decides requests no rule matches, and is deny when the project and container documents both leave it out

Explore related security:


The three tiers cover the usual progression: a new container is reachable by its unguessable URL, project-level rules give a team consistent access across containers, and container-level rules lock down what reaches production.

Permissions scale from open collaboration to strict access control, all through the same HTTP layer.