Skip to main content

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:

RoleAccess LevelDescription
API Read OnlyRead-onlyCan view GPS data, users, roles, and public endpoints
API Full AccessFull accessCan create, update, delete all resources
Custom RolesConfigurableCustom 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"
}
}
Not Implemented

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:

  1. Ensure APP_ENV is not set to production
  2. Open http://localhost:3000/v1/graphiql
  3. Click "HTTP HEADERS" at the bottom left
  4. Add your authorization header:
{
"Authorization": "Bearer YOUR_TOKEN"
}
  1. Execute your queries
warning

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 RustMeaning
accessTokenAuthorization: Bearer <user token>Act as the signed-in user
system: trueAuthorization: Bearer <SYSTEM_API_KEY>No user context (service-to-service)
anonymous: true(no Authorization header)Genuinely public / pre-auth read
(none)request never sentThrows → 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 authorize endpoint (/v1/oauth/authorize) no longer reads a static X-User-Id header from the id app either. That server-side handoff now carries a signed X-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 appapp_id must be a configured [internal_apps.<app_id>] entry; unknown apps are rejected.
  • Canonical UUID subject — a subject that does not parse as a Uuid is rejected as Malformed; 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 authorize handoff from the id app (/v1/oauth/authorize).
  • Audit ingestion (POST /v1/internal/audit, GraphQL logAuditEvent) and the token-less logout-audit write, 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:

KeyEnv overridePurpose
[internal_apps.<app>].secretHEIMDALL__INTERNAL_APPS__<APP>__SECRETPer-app HMAC secret (id, backend, policies)
id_app_assertion_ttl_secsHEIMDALL__ID_APP_ASSERTION_TTL_SECSFreshness 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:

EndpointRule
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/oauthSystem 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:

RouteMatch
/v1/users/oauthexact
/v1/users/emailprefix (covers /v1/users/email/{email})
/v1/sessionsprefix (covers /v1/sessions/{token})
/v1/auth/loginexact
/v1/auth/2fa/checkexact
/v1/auth/verify-emailexact
/v1/auth/reset-passwordprefix (covers /v1/auth/reset-password/complete)
/v1/auth/register/verifyexact
/v1/internal/auditexact
/v1/gqlexact
/v1/wsexact

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:

  1. 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-Protocol entry. Mint it via REST POST /v1/ws/ticket or GraphQL mintWsTicket — both require a user context (session or OAuth access token; an API-key-only credential is rejected 403) and return { ticket, expiresIn }. Then open new WebSocket(url, ['heimdall-ticket', ticket]).
  2. Authorization: Bearer <token> header (non-browser clients).
  3. No credential → anonymous (accepted, not a 401) — may subscribe only to public* / 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

Security Tips
  1. Never expose your API token in client-side code or public repositories
  2. Use HTTPS in production to encrypt token transmission
  3. Rotate tokens regularly for enhanced security
  4. Use environment variables to store tokens
  5. 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 ContextLimitWindow
System API keyUnlimited
Non-system API key1200 requests60 seconds (configurable)
Authenticated user600 requests60 seconds (configurable)
Anonymous (no auth)120 requests60 seconds (configurable)

Rate limit exceeded response: 429 Too Many Requests (HTTP 429) with the following headers:

  • X-RateLimit-Limit — maximum requests allowed in the window
  • X-RateLimit-Remaining — requests remaining in the current window (0 when exceeded)
  • X-RateLimit-Reset — Unix timestamp when the window resets
  • Retry-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.

Next Steps