Authentication
Section titled “Authentication”Hoody Exec’s @token magic comment adds shared-secret authentication to any script, covering both HTTP requests and WebSocket connections. You do not add middleware, an auth library, or JWT infrastructure. Put the comment in each script you want to protect: in a directory’s pre.ts or post.ts it has no effect.
// @token my-secret-key-123
return { data: 'only authenticated requests see this' };Unauthenticated requests get 401 Unauthorized. Authenticated requests run your script normally.
The token check
Section titled “The token check”- You add
// @token <secret>at the top of your script. - The server checks every incoming request except
OPTIONS(see CORS preflight) for a matching token before your code runs. - A matching token lets the script execute normally.
- A missing or wrong token returns
401 Unauthorized, and your code never runs. (A remotecapabilitiesrequest is the one other exception: without a valid token it still answers200, saying that a token is required and listing the names, never the secrets, of its valid@remote-tokenlines.)
Runs started by @schedule carry no token and are not checked. Firing a token-protected script by hand through the schedules API is refused with 403 unless the request passes force: true or the server was started with --schedules-trigger-allow-token true.
@token takes one word: in // @token s3cret rotate monthly the token is s3cret and the rest of the line is ignored, with a warning. @token is repeatable: each line adds one accepted token (like every magic comment, it must sit in the first 30 lines, before any code; a @token further down is ignored and leaves the endpoint public), and a request that matches any of them passes. Remove a line to revoke that token, but keep at least one: with no @token line left, the endpoint is public. When you edit a copy read back through the API, where tokens show as [REDACTED], the placeholders are filled back in by position, so deleting or reordering one can keep the wrong token; write the real values of the tokens you keep instead. A change applies to new requests and new WebSocket connections; a WebSocket that is already open stays connected until it closes. After any edit to the script, including a token change, connections opened before and after it no longer receive each other’s broadcasts, so reconnect clients that must share them. Remote sessions are the exception: they are checked again and closed once the script’s current tokens no longer admit them (removing every @token when no @remote-token is declared makes remote messages and calls public, so open sessions stay; eval still needs an eval-scoped @remote-token). The check runs about every 15 seconds, so a revoked session can stay open until then.
The token check happens at the server level, before the script’s context is built and its metadata constructed. There is no way for a script to see or override the gate.
@token also admits remote operations (event streams, send, call) that the script has switched on with @remote-messages or @remote-call, unless the script declares a @remote-token, which then replaces it for those operations. @token alone switches nothing on. It never unlocks remote eval.
Credential methods
Section titled “Credential methods”A client can send the token four ways. The server checks them in the order below and uses the first usable one.
Priority order
Section titled “Priority order”| Priority | Method | Header / Parameter |
|---|---|---|
| 1a | Bearer token | Authorization: Bearer <token> |
| 1b | Basic auth (password field) | Authorization: Basic base64(user:token) |
| 2 | X-Token header | X-Token: <token> |
| 3 | Query parameter | ?token=<token> |
If multiple sources are present, the highest-priority usable one is used. For example, if both Authorization: Bearer and X-Token are sent, only the Bearer value is checked. An empty Bearer value, or a Basic value that does not decode to user:password or has an empty password, is skipped and the next method is tried. A non-empty wrong credential is not skipped, so it blocks the lower-priority ones. Remote operations differ: they try the credentials from every source (the Authorization value, the first X-Token, the first two ?token= values and WebSocket subprotocol tokens, up to 8 in all), so a wrong Bearer value does not hide a valid X-Token.
Bearer token
Section titled “Bearer token”Bearer is the standard approach for API clients and SDKs.
curl -H "Authorization: Bearer my-secret-key-123" \ "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data"const res = await fetch('https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data', { headers: { 'Authorization': 'Bearer my-secret-key-123' }});import requestsres = requests.get('https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data', headers={'Authorization': 'Bearer my-secret-key-123'})One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Runs the same authenticated request from a browser address bar, with the token sent as a Bearer credential.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data&method=GET&bearer_token=my-secret-key-123&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
The scheme name is case-insensitive (Bearer, bearer, BEARER all work).
Basic auth
Section titled “Basic auth”The server matches the @token value against the password field of HTTP Basic auth. It ignores the username, so send anything or nothing.
That makes @token work directly with:
curl -uflag- HTTP clients that only support Basic auth
- Legacy systems and webhook integrations
# Username "admin", password is the token; the username is ignoredcurl -u admin:my-secret-key-123 \ "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data"# No username, just the token as passwordcurl -u :my-secret-key-123 \ "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data"# Any username works; only the password matterscurl -u monitoring-bot:my-secret-key-123 \ "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data"X-Token header
Section titled “X-Token header”A custom header, useful when a proxy or gateway has already claimed Authorization.
curl -H "X-Token: my-secret-key-123" \ "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data"Query parameter
Section titled “Query parameter”Pass the token in the URL. This suits quick testing, webhook callbacks, and browser links.
curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/data?token=my-secret-key-123"Rejection response
Section titled “Rejection response”When authentication fails on an ordinary request, the server returns the response below. Remote operations answer in their own error format instead (REMOTE_TOKEN_REQUIRED or REMOTE_TOKEN_INVALID):
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer realm="hoody-exec"Content-Type: application/json
{ "error": "Unauthorized", "message": "This endpoint requires authentication. Provide a valid token via Authorization: Bearer <token> header, X-Token header, ?token= query parameter, or HTTP Basic Auth."}- The
WWW-Authenticateheader advertises Bearer. The server accepts Basic credentials when the client sends them, but this response does not trigger a browser username and password dialog. - The response body is the same whether the token is missing or wrong, so the reply leaks nothing about which one it was.
- If the script configures CORS (
@cors), the 401 response carries the CORS headers too.
CORS preflight and tokens
Section titled “CORS preflight and tokens”The server answers every OPTIONS request with 204 before routing and the token check, whatever the script’s magic comments say, so a script like this one works with browser clients (a request a proxy hook sends to its target script is the exception: it is checked like any other method):
// @token my-secret-key-123// @cors reflective
return { data: 'protected + CORS-enabled' };Browsers send an OPTIONS preflight before a cross-origin request that needs one, such as any request with an Authorization or X-Token header (a plain GET with ?token= skips it and goes straight to the token check), and the HTTP spec does not let a preflight carry credentials. Requiring a token on the preflight would break CORS for every browser client.
The exchange runs in two steps:
- The browser sends
OPTIONSwithOriginandAccess-Control-Request-Method, and the server returns204with the CORS headers. No token required. - The browser sends the real
GETorPOSTwithAuthorization: Bearer <token>, and the server checks the token before running the script.
This follows the CORS spec and is not a way around the gate: the request that reaches your code is still checked.
WebSocket authentication
Section titled “WebSocket authentication”The @token gate also protects WebSocket connections. The token is checked when the connection is opened, before any of your script’s handlers run.
// @token my-secret-key-123// @mode worker// @websocket
ws.on('message', (socket, data) => socket.send('echo:' + data));Send the token when you connect:
// Works in all WebSocket clients (including browsers)// The WebSocket URL is the script's own route (file-based routing), not a dedicated /ws pathconst ws = new WebSocket('wss://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/realtime/echo?token=my-secret-key-123');// Node.js with the `ws` package (browsers don't allow custom WebSocket headers)import WebSocket from 'ws';
const ws = new WebSocket('wss://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/realtime/echo', { headers: { 'Authorization': 'Bearer my-secret-key-123' }});Without a valid token, the connection opens and is closed at once with close code 4401 and reason Unauthorized. Handle the client’s close event to detect it. (A directory pre.js that refuses an upgrade works differently: that is a plain HTTP status with no handshake.)
Security details
Section titled “Security details”Constant-time comparison
Section titled “Constant-time comparison”Token comparison uses SHA-256 hashing on both sides followed by crypto.timingSafeEqual:
SHA-256(provided) === SHA-256(expected) // constant-timeThis prevents timing attacks: the comparison takes the same amount of time regardless of how many characters match.
Token redaction
Section titled “Token redaction”By default, Hoody Exec never returns the token in an API response or writes it to its logs:
| Surface | Redaction |
|---|---|
| Access logs | The ?token= value is never logged: the parameter is removed from the logged URL or its value replaced with [REDACTED] |
| Referer headers in logs | ?token= redacted |
Scripts API (/api/v1/exec/scripts/read) | Raw content shows // @token [REDACTED] |
| Magic Comments API | token field returns [REDACTED] |
metadata.parameters in your script | ?token= query param is removed before your code runs when it is the credential used |
Encoded bypass attempts (%74oken=) | Caught by URL-parser fallback and redacted |
What your own script logs or returns (for example console.log(req.headers.authorization), or a request body logged in full mode) is recorded as is.
Token isolation
Section titled “Token isolation”When a client authenticates with ?token=, the server strips the token from the request URL before your script runs, so your script never sees it. If the request authenticates with a higher-priority credential, such as a Bearer header, a ?token= parameter is not removed and stays visible to the script:
// @token my-secret
// Client calls: /api/data?token=my-secret&page=2// Your script sees:metadata.parameters // → { page: "2" } (no "token" key)metadata.path // → /api/data (clean)Examples
Section titled “Examples”Protected API endpoint
Section titled “Protected API endpoint”// @token sk_prod_a8f2e9c1d4b6// @cors reflective
// `db` stands for your own database client; you provide itconst userId = metadata.parameters.id;const user = await db.getUser(userId);return user;Webhook receiver with token
Section titled “Webhook receiver with token”// @token whsec_github_abc123
if (req.method !== 'POST') { res.statusCode = 405; return { error: 'Method Not Allowed' };}
// `processWebhook` stands for your own handler; you provide itconst payload = JSON.parse(req.rawBody.toString());await processWebhook(payload);return { received: true };# GitHub webhook configured with:# URL: https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/webhooks/github?token=whsec_github_abc123Token with worker mode and WebSocket
Section titled “Token with worker mode and WebSocket”// @token realtime-secret-456// @mode worker// @websocket// @cors reflective
ws.on('message', (socket, data) => { // Broadcast to this script's other clients. Do not keep sockets in `shared`: // it is shared by every script of the instance, whatever their tokens. ws.broadcast(data, socket);});Programmatic management
Section titled “Programmatic management”# Read magic comments (token is redacted in response)hoody exec magic comments get -c CONTAINER_ID --path "default/1/api/data.ts" -o json# → { "path": "default/1/api/data.ts", "comments": { "token": "[REDACTED]", "tokens": ["[REDACTED]"], "cors": "reflective" } }const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER});
// Read magic comments (token is redacted)const comments = await containerClient.exec.magicComments.get({ path: 'default/1/api/data.ts' });console.log(comments.data.comments); // { token: "[REDACTED]", tokens: ["[REDACTED]"], cors: "reflective" }
// Update the token. `comments` is an object keyed by directive name,// not a string of comment linesawait containerClient.exec.magicComments.update({ path: 'default/1/api/data.ts', comments: { token: 'new-secret-key' }});# Read magic comments (token is redacted in response)curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/read?path=default/1/api/data.ts"# → { "path": "default/1/api/data.ts", "comments": { "token": "[REDACTED]", "tokens": ["[REDACTED]"], "cors": "reflective" } }
# Update token via APIcurl -X PUT "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/update" \ -H "Content-Type: application/json" \ -d '{ "path": "default/1/api/data.ts", "comments": { "token": "new-secret-key" } }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Reads or updates the script’s magic comments; the token value itself is always redacted in the response.
comments is an object keyed by directive name, the same shape the read returns. A read always includes tokens, one [REDACTED] entry per token: when you send back an edited read with a new token, change tokens to match or drop it, or the request fails with 400 because token and tokens disagree (leaving both redacted is fine). A string is
rejected with 400 comments must be an object; a missing comments gets 400 comments object is required.
# Read
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/read?path=default/1/api/data.ts&method=GET&response=transparent
# Update
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/magic-comments/update&method=PUT&json={"path":"default/1/api/data.ts","comments":{"token":"new-secret-key"}}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.