Authentication
The Heimdall API uses Bearer token authentication for secure access to protected endpoints.
Authentication Methods
Bearer Token Authentication
All authenticated requests must include an Authorization header with a Bearer token:
Authorization: Bearer YOUR_API_TOKEN
Obtaining an API Token
API tokens can be created in the Backend Console by users with the appropriate permissions, or by system administrators.
Token Roles
API keys are assigned roles that determine their permissions:
| Role | Access Level | Description |
|---|---|---|
| API Read Only | Read-only | Can view GPS data, users, roles, and public endpoints |
| API Full Access | Full access | Can create, update, delete all resources |
| Custom Roles | Configurable | Custom roles with specific permissions |
See the Authentication System for details on roles and permissions.
Making Authenticated Requests
Using cURL
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://api.elcto.com/v1/gps/current
Using JavaScript (Fetch)
const response = await fetch('https://api.elcto.com/v1/gps/current', {
headers: {
'Authorization': 'Bearer YOUR_TOKEN',
'Content-Type': 'application/json'
}
});
const data = await response.json();
Using Python (Requests)
import requests
headers = {
'Authorization': 'Bearer YOUR_TOKEN',
'Content-Type': 'application/json'
}
response = requests.get(
'https://api.elcto.com/v1/gps/current',
headers=headers
)
data = response.json()
Validate Your Token
You can validate your token using the validation endpoint:
POST /v1/auth/validate
Content-Type: application/json
{
"token": "YOUR_TOKEN"
}
Response:
{
"valid": true,
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"role": "ADMIN"
}
}
This endpoint is a placeholder and currently returns 501 Not Implemented. Use the GraphQL me query to inspect the authenticated context instead.
GraphQL Authentication
The GraphQL endpoint (/v1/gql) requires authentication for all queries and mutations. Include the Authorization header with your Bearer token:
curl -X POST https://api.elcto.com/v1/gql \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "{ me { userId apiKeyId roles permissions isSuperAdmin } }"}'
Response:
{
"data": {
"me": {
"userId": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": null,
"roles": ["Admin"],
"permissions": ["*:*"],
"isSuperAdmin": true
}
}
}
Using GraphiQL (Development Only)
The GraphiQL IDE (/v1/graphiql) is only available in development environments. To use it:
- Ensure
APP_ENVis not set toproduction - Open http://localhost:3000/v1/graphiql
- Click "HTTP HEADERS" at the bottom left
- Add your authorization header:
{
"Authorization": "Bearer YOUR_TOKEN"
}
- Execute your queries
GraphiQL is disabled in production environments for security. Use the /v1/gql endpoint directly with your HTTP client in production.
First-Party Webapp Authentication (Fail-Closed)
The Next.js webapps (id, backend, policies) never talk to the Rust API
directly from the browser. They proxy through their own API routes, which call the
Rust backend via the @elcto/api client. That client is fail-closed: every
call must carry exactly one explicit auth signal, or it throws AuthRequiredError
(surfaced to the browser as 401 with code AUTH_REQUIRED):
| Signal (client option) | Sent to Rust | Meaning |
|---|---|---|
accessToken | Authorization: Bearer <user token> | Act as the signed-in user |
system: true | Authorization: Bearer <SYSTEM_API_KEY> | No user context (service-to-service) |
anonymous: true | (no Authorization header) | Genuinely public / pre-auth read |
| (none) | request never sent | Throws → 401 AUTH_REQUIRED |
There is no silent fallback from a missing user token to the system key. This
prevents a dropped/expired session from being escalated to full *:* system
access.
X-User-Id (removed)
The GraphQL gateway no longer honors an X-User-Id header. Historically a
system API key could present X-User-Id and be downgraded to that user's
roles/permissions; that grandfather machinery was removed once all first-party
apps switched to sending the signed-in user's own accessToken. The header is
now ignored — a system-key request runs as the raw system context regardless
of any X-User-Id value. First-party apps act on behalf of a user solely by
sending that user's accessToken.
The OAuth
authorizeendpoint (/v1/oauth/authorize) no longer reads a staticX-User-Idheader from theidapp either. That server-side handoff now carries a signedX-ID-App-Assertion(see below), so the Rust API cryptographically verifies which end user the ID app is acting for instead of trusting an unauthenticated header value.
X-ID-App-Assertion (internal first-party app assertion)
First-party internal apps (id, backend, policies) sometimes need to act on
behalf of a specific end user in a context where no user access token is
available — the server-side OAuth authorize handoff, and token-less audit
writes (e.g. logout). Instead of trusting a caller-supplied user id, the app
mints a short-lived, signed X-ID-App-Assertion header that the Rust API
verifies (heimdall_auth::id_app_assertion). This primitive replaced the old
static X-User-Id trust.
Token format — exactly five dot-separated fields:
<app_id>.<subject_uuid>.<issued_at_unix>.<nonce>.<hmac_b64url>
The signed message is "<app_id>|<subject>|<issued_at>|<nonce>", HMAC-SHA256'd
with the per-app secret and base64url-encoded (no padding). The nonce carries an
idaa_ namespace prefix.
<subject> must be a canonical UUID — lowercase, hyphenated. verify_assertion
re-parses the subject field as a Uuid and rebuilds the signed message from its
canonical form, so a minter that signs a differently-cased or padded subject
computes a different HMAC than the API recomputes, and every such assertion fails
as BadSignature. The @elcto/api minter (mintIdAppAssertion) canonicalizes the
subject before signing and throws if it is not a UUID.
Verification invariants (all enforced by verify_assertion):
- Allowlisted app —
app_idmust be a configured[internal_apps.<app_id>]entry; unknown apps are rejected. - Canonical UUID subject — a subject that does not parse as a
Uuidis rejected asMalformed; the signature is always checked against the canonical rendering, never the raw field. - Per-app secret — the HMAC key is per app, never shared, so one app's assertion can never be replayed as another's. Signature comparison is constant-time.
- Short TTL, both directions — an assertion older than
id_app_assertion_ttl_secs, or future-dated beyond a small clock-skew tolerance, is rejected. - Single-use — the nonce is claimed in Redis (
SET NX EX) on the first successful verify; a replayed assertion is rejected.
Where it is required:
- The server-side OAuth
authorizehandoff from theidapp (/v1/oauth/authorize). - Audit ingestion (
POST /v1/internal/audit, GraphQLlogAuditEvent) and the token-lesslogout-auditwrite, when a service caller needs to attribute the event to a specific end-user actor (see the audit trust matrix in Audit Event System).
Config keys:
| Key | Env override | Purpose |
|---|---|---|
[internal_apps.<app>].secret | HEIMDALL__INTERNAL_APPS__<APP>__SECRET | Per-app HMAC secret (id, backend, policies) |
id_app_assertion_ttl_secs | HEIMDALL__ID_APP_ASSERTION_TTL_SECS | Freshness window in seconds (default 30) |
Endpoints that require the system API key
Two "public" REST endpoints (reachable without the middleware rejecting them, so NextAuth can call them during sign-in) enforce the system key in the handler:
| Endpoint | Rule |
|---|---|
GET /v1/users/email/{email} | System key → may query any email. A regular user → only their own email (403 otherwise). No/invalid auth → 401. |
POST /v1/users/oauth | System key required — creates/updates a user from an OAuth login. Any non-system caller → 401. |
System Key Route Allowlist
The system API key is a god-credential (unlimited rate limit, * scopes) meant for
service-to-service and pre-session flows — NextAuth sign-in, the Discord bot / webapp
relay, internal audit writes. AuthMiddleware (crates/heimdall-rest/src/middleware.rs,
system_key_allowed()) confines it to an explicit allowlist: on any route not in
this list, a system key is rejected with 403 Forbidden ("System key not permitted on
this route") even though it authenticates successfully. This applies at both middleware
enforcement points (the public-routes branch and the protected-routes branch), so it
also protects "public" routes like /v1/users/oauth.
The 11 allowlisted routes:
| Route | Match |
|---|---|
/v1/users/oauth | exact |
/v1/users/email | prefix (covers /v1/users/email/{email}) |
/v1/sessions | prefix (covers /v1/sessions/{token}) |
/v1/auth/login | exact |
/v1/auth/2fa/check | exact |
/v1/auth/verify-email | exact |
/v1/auth/reset-password | prefix (covers /v1/auth/reset-password/complete) |
/v1/auth/register/verify | exact |
/v1/internal/audit | exact |
/v1/gql | exact |
/v1/ws | exact |
GraphQL residual (accepted, not a gap): /v1/gql is allowlisted, so a system key
still reaches every GraphQL resolver. This is intentional — GraphQL doesn't need its
own route-level allowlist because individual resolvers already gate themselves with
is_system_key() where system-only behavior matters, e.g. the oauthSignin mutation
(crates/heimdall-graphql/src/mutations/auth.rs, GraphQL equivalent of
POST /v1/users/oauth) and the createAuditEvent mutation
(crates/heimdall-graphql/src/mutations/audit.rs, requires is_system_key() or
audit:write) — mirroring the REST handlers in the table above. Other resolvers
(platform_accounts, two_factor, oauth, users) use is_system_key() as one
branch of a broader author-or-system-or-admin check. The REST route allowlist exists
because REST handlers don't uniformly self-check this way; GraphQL already does, so
it doesn't need one.
WebSocket Authentication
The GET /v1/ws upgrade is authenticated by exactly one of, in order:
- Ticket subprotocol (browsers). Browsers cannot set request headers, so they first
mint a short-lived, single-use ticket and carry it as a
Sec-WebSocket-Protocolentry. Mint it via RESTPOST /v1/ws/ticketor GraphQLmintWsTicket— both require a user context (session or OAuth access token; an API-key-only credential is rejected403) and return{ ticket, expiresIn }. Then opennew WebSocket(url, ['heimdall-ticket', ticket]). Authorization: Bearer <token>header (non-browser clients).- No credential → anonymous (accepted, not a
401) — may subscribe only topublic*/vessels.
The legacy ?token= query parameter and the cookie path have been removed. See the
WebSocket API page for the full protocol, including the
?userId= system-key identity relay.
// Browser: mint a single-use ticket, then carry it as a subprotocol (no ?token=).
const { ticket } = await fetch('https://api.elcto.com/v1/ws/ticket', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());
const ws = new WebSocket('wss://api.elcto.com/v1/ws', ['heimdall-ticket', ticket]);
ws.onopen = () => {
// Subscribe to GPS updates
ws.send(JSON.stringify({
type: 'Subscribe',
channel: 'gps'
}));
};
Authentication Errors
401 Unauthorized
Returned when no authentication token is provided or the token is invalid:
{
"error": "Unauthorized",
"message": "Invalid or missing authentication token"
}
403 Forbidden
Returned when the authenticated user lacks sufficient permissions:
{
"error": "Forbidden",
"message": "Insufficient permissions for this operation"
}
Security Best Practices
- Never expose your API token in client-side code or public repositories
- Use HTTPS in production to encrypt token transmission
- Rotate tokens regularly for enhanced security
- Use environment variables to store tokens
- Implement token expiration policies in your applications
Rate Limiting
All requests are subject to context-aware rate limiting via a fixed-window Redis counter. Limits are keyed by authentication identity, not IP:
| Authentication Context | Limit | Window |
|---|---|---|
| System API key | Unlimited | — |
| Non-system API key | 1200 requests | 60 seconds (configurable) |
| Authenticated user | 600 requests | 60 seconds (configurable) |
| Anonymous (no auth) | 120 requests | 60 seconds (configurable) |
Rate limit exceeded response: 429 Too Many Requests (HTTP 429) with the following headers:
X-RateLimit-Limit— maximum requests allowed in the windowX-RateLimit-Remaining— requests remaining in the current window (0 when exceeded)X-RateLimit-Reset— Unix timestamp when the window resetsRetry-After— seconds to wait before retrying
These headers are emitted on rate-limited requests (excluded: system API keys, exempt paths, fail-open Redis scenarios).
Exempt endpoints (bypass rate limiting):
/health,/v1/health— health checks/v1/ws— WebSocket upgrades/v1/openapi.json,/v1/schema— API documentation/v1/swagger-ui/*— Swagger UI assets
Failure mode: If Redis is unavailable, requests are allowed (fail-open) to prevent API outages.