The Hoody Authentication API covers account signup, sign-in (email/password, GitHub, Google, device flow), JWT-based session management, identity claims for third-party verification, and two-factor authentication (2FA). Use these endpoints to register new accounts, exchange credentials for access and refresh tokens, manage the current session, and protect accounts with TOTP-based 2FA.
All authenticated endpoints accept Authorization: Bearer <jwt>. After 2FA is enabled on an account, sensitive token-mutation endpoints additionally require a TOTP code.
Returns the ED25519 public key(s) used by Hoody to sign all API responses (X-Hoody-Signature header), identity claims issued at login, and container authorization claims.
This endpoint is intentionally public and requires no authentication.
response_signed_data: ${t}\n${method}\n${status}\n${path}\n${responseBody} — the newline-joined tuple of timestamp, HTTP method, response status, request path, and response body. The body is the UTF-8 string of the JSON payload.
identity_claim_signed_data: base64url(JSON.stringify(claim_payload)) — the b64url string itself (UTF-8 bytes).
container_claim_signed_data: same as identity_claim_signed_data.
replay_tolerance_seconds: recommended maximum age (|now - t|) for response-signature freshness checks.
For response signatures: parse the X-Hoody-Signature header and verify sig against the newline-joined tuple ${t}\n${method}\n${status}\n${path}\n${responseBody}.
For identity/container claims: verify claim.signature_hex against the UTF-8 bytes of claim.payload_b64.
If kid in a signature/claim does not match any cached key, re-fetch this endpoint.
An identity claim is a short-lived, ED25519-signed credential Hoody returns to a freshly authenticated user. It proves “Hoody authenticated this user” to systems outside Hoody — container programs, third-party services, downstream APIs — and is verified offline against the public key fetched from GET /api/v1/meta/public-key.
The claim is returned (when signing is configured on the server) by:
Password login — POST /api/v1/users/auth/login (see below)
2FA completion — POST /api/v1/users/auth/2fa/verify (see below)
Email verification in tokens mode — POST /api/v1/auth/verify-email (see below)
Device flow terminal — POST /api/v1/auth/device/token
Hosted-auth exchange — POST /api/v1/auth/exchange
It is omitted when response_mode=intent is requested (the hosted-auth UI flow defers issuance to the PKCE exchange) and when HOODY_SIGNING_PRIVATE_KEY is not configured on the server instance.
Signature verifies against the UTF-8 bytes of payload_b64 (not the decoded JSON), using the public key whose kid matches payload.kid and bundle.kid.
payload.claim_type === "identity".
payload.iss === "hoody-api".
payload.exp > now and payload.exp > payload.iat.
payload.iat <= now + 300 (300-second clock skew tolerance).
payload.kid === bundle.kid.
If your flow uses audience binding, both directions must hold: the verifier’s audience matches payload.aud, and payload.aud matches the verifier’s identifier (strict two-way audience semantics).
There are three patterns for letting a proxied app (one running inside a Hoody container, routed through the Hoody proxy) decide whether the inbound request carries a valid Hoody identity. Try them in this order:
Native hoody-identity permission group. The built-in proxy permission group that exposes the parsed identity claim to proxied apps. See the hoody-identity permission group.
App-level header. Read a non-reserved request header that the upstream caller (your gateway or the Hoody dashboard) sets to the claim bundle. The convention used by Hoody and downstream consumers is X-Hoody-Claim: <payload_b64>.<signature_hex> with a pinned kid.
Proxy hook. A request-time hook in your app’s hoody.json that receives the headers (and claim bundle, if forwarded) and decides whether to allow the request. See the identity-claim-auth-gate hook recipe, which likewise reads from the non-reserved x-hoody-claim header.
For container-side, per-process claims (claims that prove a Hoody user authorized a specific command inside a single container, not the cross-app identity claim described here), see Identity claims on the Containers API.
GET endpoint the verification page navigates to. Verifies the provider is fully configured (302 back to the device page with ?error=provider_unavailable, ticket intact, when not), then consumes the device_verify_ticket + __Host-device_verify cookie atomically and redirects to the provider with a server-injected device_binding + attempt nonce. Sets Referrer-Policy: no-referrer.
OAuth intent: login (default). star_check is accepted but ignored (retired).
redirect_uri
query
string
Yes
Frontend URL to redirect to after OAuth completes (must be on an allowed domain).
code_challenge
query
string
Yes
PKCE code_challenge (base64url SHA-256 of code_verifier). Required — all OAuth flows must use PKCE post-migration.
invite_code
query
string
No
Optional invite code (“coupon”) captured from the signup link. Normalized and hashed at redirect time — only the hash travels in the OAuth state, never the raw code. Memorized hash-only on a NEW account and applied automatically; not validated here.
Frontend URL to redirect to after OAuth completes (must be on an allowed domain).
code_challenge
query
string
Yes
PKCE code_challenge (base64url SHA-256 of code_verifier). Required — all OAuth flows must use PKCE post-migration.
invite_code
query
string
No
Optional invite code (“coupon”) captured from the signup link. Normalized and hashed at redirect time — only the hash travels in the OAuth state, never the raw code. Memorized hash-only on a NEW account and applied automatically; not validated here.
GET endpoint the popup navigates to. Consumes the launch ticket atomically and runs the existing OAuth redirect flow. Sets Referrer-Policy: no-referrer.
Issues a device_code (polled by the CLI) and a short, hand-typeable user_code (shown to the human). Public, no auth. RFC-8628-inspired but deliberately not a standards-compliant device grant: no client_id/grant_type, lifecycle errors are nested under data, and the poll interval is a fixed 5s after slow_down.
Page-only helper of the RFC-8628-inspired device flow (mirrors the RFC recommendation that the user can deny). Cookie + ticket gated, no credentials required — possession of the live ticket + cookie is the refusing authority. Flips the pending row to denied; the terminal poll then reports access_denied. status=pending-conditional: an approved row can never be un-approved.
Page-only helper. Verifies email/username + password with FULL login parity (shared per-account throttle, timing-normalized bcrypt) behind the device_verify_ticket + __Host-device_verify cookie gate. NEVER returns session tokens: no-2FA returns {status:"approved"} (tokens mint only at /device/token); 2FA returns {requires_2fa, temp_token} (device-bound partial, no code_challenge). Credential failures do NOT consume the ticket. Feature-flag off returns 404; schema-invalid body returns 422.
The provided username/email or password is incorrect.
Verify your credentials and try again, or use the password reset feature.
EMAIL_NOT_VERIFIED
Email not verified
Returned when the password is correct but the account’s email address has not been verified yet. Reachable only after bcrypt confirms the password, so it is not an enumeration oracle. Response carries data.email so the client can offer a ‘resend verification email’ CTA without re-prompting.
Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email.
{
"statusCode":403,
"error":"ACCOUNT_BANNED",
"message":"Your account is banned"
}
Error Code
Title
Description
Resolution
ACCOUNT_BANNED
Account banned
Your account has been banned and cannot access this resource.
Contact support for information about your account status.
{
"statusCode":404,
"error":"Not Found",
"message":"Device flow is disabled on this instance"
}
{
"statusCode":410,
"error":"Gone",
"message":"Ticket invalid/consumed/superseded, cookie mismatch, or row expired/denied — re-enter the user code"
}
{
"statusCode":422,
"error":"Validation Error",
"message":"Validation failed: /ticket must match pattern \"^[0-9a-f]{64}$\""
}
Error Code
Title
Description
Resolution
VALIDATION_ERROR
Request validation failed
The request body failed schema validation (e.g. a malformed ticket; for /device/login also a missing identifier or out-of-bounds field lengths).
Correct the request fields and retry.
{
"statusCode":429,
"error":"Too Many Requests",
"message":"Rate limit exceeded"
}
Error Code
Title
Description
Resolution
RATE_LIMIT_EXCEEDED
Rate limit exceeded
You have exceeded the rate limit for this endpoint.
Wait before making additional requests, or upgrade your plan for higher limits.
Polled by the CLI while the user completes the browser step. Returns 400 + {data:{error}} for lifecycle states (authorization_pending | slow_down | access_denied | expired_token), 200 + token-set on approval (single-use; also requires the approving user’s session generation to still be current — a password reset / logout-all after approval yields expired_token), 429 on the outer flood guard. Public, no auth. Lifecycle errors are nested under data, unlike RFC 8628 §3.5.
Page-only helper. On a live pending row, mints a one-time device_verify_ticket and sets the __Host-device_verify cookie. Leaks only client_name + coarse status.
POST endpoint that issues a one-shot launch ticket bound to the request Origin header. The frontend navigates the popup to the returned launch_url, which consumes the ticket and runs the existing PKCE-protected OAuth flow with state_id + opener_origin plumbed through.
Verify the email address using the token from the verification email. Default response returns full login credentials. When response_mode=intent + code_challenge are provided, returns an opaque auth_intent_token for PKCE exchange (hosted auth UI flow). If 2FA is enabled on the account, returns requires_2fa + temp_token instead. On success, the login response includes an identity_claim (see Identity claims).
Retrieve the profile of the currently authenticated user. Works with JWT, auth token, or Basic authentication. When authenticated with an auth token, response includes data.auth_token introspection details (permissions and realm restrictions). This endpoint works even for banned users (read-only access). When authenticated with an auth token that lacks the resources.read_account permission, the response is reduced to identity fields (id, username, alias, public_key, timestamps); email and other account PII are omitted.
Retrieve the profile of the currently authenticated user. Works with JWT, auth token, or Basic authentication. When authenticated with an auth token, response includes data.auth_token introspection details (permissions and realm restrictions). This endpoint works even for banned users (read-only access). When authenticated with an auth token that lacks the resources.read_account permission, the response is reduced to identity fields (id, username, alias, public_key, timestamps); email and other account PII are omitted.
Authenticate with username and password to receive a JWT access token (expires in 1 day) and a refresh token (expires in 7 days). Use the access token in the Authorization header for subsequent requests: Authorization: Bearer <token>. On success, the response also includes an identity_claim proving the login to third parties — see Identity claims.
Check the error message for specific field requirements and correct your input.
MISSING_REQUIRED_FIELD
Required field missing
One or more required fields are missing from the request.
Include all required fields as specified in the API documentation.
{
"statusCode":401,
"error":"INVALID_CREDENTIALS",
"message":"Invalid email or password"
}
Error Code
Title
Description
Resolution
INVALID_CREDENTIALS
Invalid credentials
The provided username/email or password is incorrect.
Verify your credentials and try again, or use the password reset feature.
EMAIL_NOT_VERIFIED
Email not verified
Returned when the password is correct but the account’s email has not been verified yet. Reachable only after bcrypt confirms the password, so it is not an enumeration oracle. Response carries data.email so the client can offer a ‘resend verification email’ CTA without re-prompting.
Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email.
Log out the current user. Creates an audit log entry. In a stateless JWT setup, the client should discard the token. This endpoint works even for banned users.
Exchange a valid refresh token for a new access token and a new refresh token. Send the refresh token in the Authorization header as Authorization: Bearer <refreshToken>, or in the request body.
Mint a fresh, audience-bound identity claim for the authenticated caller without a re-login. First-party JWT sessions only — auth tokens, HTTP Basic, and impersonated sessions are rejected (403). The claim proves “Hoody authenticated this user” to the audience named in the request; third parties verify it offline against GET /api/v1/meta/public-key. Claim lifetime is clamped to [60s, min(server ceiling — default 24h, remaining JWT lifetime)], default 1 hour. When the underlying JWT has fewer than 60 seconds of validity remaining, the endpoint returns 400 REFRESH_REQUIRED. Dual rate limits apply (per-caller refresh budget and global issuance budget). See Identity claims.
Consumer identifier this claim is bound to (e.g. your app hostname). Printable ASCII without whitespace or double quotes. Verifiers reject the claim unless they expect exactly this audience.
expires_in
integer
No
Requested claim lifetime in seconds. Clamped to [60, min(server ceiling, remaining JWT lifetime)]. Default: server-configured (1h).
Begin 2FA setup. Requires the current password for verification. Returns a QR code for the authenticator app and backup codes. Save the backup codes securely — they are shown only once.
Verify and complete 2FA setup by providing the first code from your authenticator app. This confirms the setup is working correctly. On success, all sessions are revoked and a fresh token / refreshToken pair is returned (sessions_revoked: true). Adopt the new tokens to keep the current session alive.
Complete login by verifying a 2FA code. Use the temp_token from the login response and provide either a 6-digit OTP code or a backup code. On success, the response includes a fresh token / refreshToken pair AND an identity_claim proving the login to third parties — see Identity claims.
Check the error message for specific field requirements and correct your input.
MISSING_REQUIRED_FIELD
Required field missing
One or more required fields are missing from the request.
Include all required fields as specified in the API documentation.
INVALID_OTP_CODE
Invalid OTP code
The provided 2FA code is incorrect or has expired.
Generate a new code from your authenticator app and try again.
INVALID_BACKUP_CODE
Invalid backup code
The provided backup code is incorrect or has already been used.
Verify the backup code is correct and has not been used previously.
INVALID_TEMP_TOKEN
Invalid temporary token
The temporary token from login has expired or is invalid.
Log in again to get a new temporary token.
DEVICE_BINDING_GONE
Device authorization no longer pending
The device-flow partial token references an authorization attempt that expired, was denied, was superseded by re-entering the user code, or was already completed. Retrying the OTP cannot succeed.
Return to the device verification page and re-enter the code shown in the terminal (or restart the login from the terminal).
{
"statusCode":401,
"error":"INVALID_OTP_CODE",
"message":"Invalid or expired 2FA code",
"data":{
"attempts_remaining":3
}
}
Error Code
Title
Description
Resolution
INVALID_OTP_CODE
Invalid OTP code
The provided 2FA code is incorrect or has expired.
Generate a new code from your authenticator app and try again.
INVALID_BACKUP_CODE
Invalid backup code
The provided backup code is incorrect or has already been used.
Verify the backup code is correct and has not been used previously.
{
"statusCode":429,
"error":"Too Many Requests",
"message":"Too many failed attempts. Account locked for 15 minutes.",
"data":{
"lockout_seconds":900
}
}
Error Code
Title
Description
Resolution
TWOFACTOR_RATE_LIMIT
2FA verification locked
Too many failed 2FA verification attempts. Account is temporarily locked.
Wait for the lockout period to expire (15 minutes) before trying again.
POST /api/v1/users/auth/2fa/backup-codes/regenerate
Generate new backup codes (invalidates all existing ones). Requires password and current OTP code for security. Save the new codes securely — they are shown only once.
Enable or disable the OTP requirement for token-mutation operations. Disabling requires both password and OTP (security downgrade requires primary-factor reauth).
Disable 2FA for the account. Requires both the current password and a valid OTP code (or backup code). On success, all sessions are revoked and a fresh token / refreshToken pair is returned (sessions_revoked: true). Adopt the new tokens to keep the current session alive.