Docs/Developer/API Authentication & Versioning
DeveloperReference

API Authentication & Versioning

Which credential each Atlas route accepts, how the four authorization policies differ, and the route map for the current /api/v1 surface.

Updated 2 Oct 2026Timebase Docs

Overview#

This article covers the two things to get right before an Atlas integration works: which credential a given route accepts, and which routes make up the current API.

It goes further than Atlas API Overview on authentication. That article carries the full /api/v1 route list; the table below covers the resources a client meets first.

Two facts shape everything below:

  1. The machine-facing API is /api/v1. The version lives in the path, and there is no version header or content negotiation.
  2. Atlas has four named authorization policies. Each pins a different credential, so a bearer that works on /api/v1 does not clear /app, and the browser session cookie does not clear the machine plane.

Part 1: The API surface#

The current policy#

Question Answer
Which path is the machine-facing API? /api/v1.
Is there a version request header, an Accept profile, or content negotiation? No. The version lives in the path.
How do I discover the authoritative current surface? The OpenAPI documents Atlas serves itself: /api/v1.json and /api/i3x.json, browsable at /api/help.

Atlas serves its web interface for any URL outside the API surface, so a mistyped or out-of-date route returns 200 text/html rather than a 404. Check the response content type before parsing.

The current route names#

Purpose Route
Entity types GET /api/v1/branches/{branch}/domains/{domain}/entity-types
Relationship types GET /api/v1/branches/{branch}/domains/{domain}/relationship-types
Data types GET /api/v1/branches/{branch}/domains/{domain}/data-types
Compile a domain PUT /api/v1/branches/{branch}/domains/{domain}/world
Run a query POST /api/v1/branches/{branch}/domains/{domain}/queries
Library imports `GET
Branches GET /api/v1/branches

Two things to note when building a client:

  • Update verbs differ by resource. Entity types, relationship types and properties update with PATCH (partial merge). Data types and functions update with PUT (full replacement).
  • data-types carries the isTimeAxis flag, which is what makes a compound time-segmentable.

Check the content type, not just the status#

Because Atlas serves the interface for any unmatched URL, a client that classifies responses by status code alone reads a wrong route as a success:

# Checks the status only - a wrong route reads as 200.
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $ATLAS_TOKEN" \
  "https://atlas.example.com:4552/api/v1/branches/main/domains/PlantModel/entity-types"
# -> 200
# Checks the content type as well.
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
  -H "Authorization: Bearer $ATLAS_TOKEN" \
  "https://atlas.example.com:4552/api/v1/branches/main/domains/PlantModel/entity-types"
# -> 200 application/json

Rule for any Atlas client: treat a 2xx whose Content-Type is not application/json (or application/problem+json on failure) as a routing problem rather than a result. Assert on the content type once in your HTTP wrapper, and every future typo surfaces immediately.

Building a client against the current surface#

  1. Pull the authoritative surface. Fetch /api/v1.json from your own server and compare your client's route list against it. That document is generated from the running application, so it never drifts.
  2. Use the route names and verbs above, including PATCH for entity types, relationship types and properties.
  3. Put the branch in the path. Ontology, imports, instance maps, world, queries, validation and draft routes are all branch-scoped under /api/v1/branches/{branch}/.... Percent-encode a / inside a branch name: /api/v1/branches/feature%2Fmy-ontology/domains/PlantModel/entity-types. The encoded slash is preserved and decoded by the handler.
  4. Assert on the content type, as above.
  5. Send a bearer on every call. See Part 2.

Part 2: The four-policy authorization model#

Atlas registers four named policies. Each pins a different set of authentication schemes, and that pin determines both which credential clears the gate and what a failure looks like.

Policy Credential accepted Applies to Failure shape
ApiAccess JWT bearer only /api/v1/**, /i3x/v1/** (except GET /i3x/v1/info), /mcp 401 from the bearer handler
UiAccess Session cookie only The /app browser plane, read routes 302 to Pulse for a page load, 401 from the in-handler checks on the identity routes
UiAdministratorAccess Session cookie only, plus the pulse:administrator role /app admin mutations 401 unauthenticated, 403 authenticated without the role
HubAccess Cookie or bearer /hubs/connector, /hubs/v1/branches/{branch}/domains/{domain}/subscribe, /hubs/logs 401, never a redirect

The default authentication scheme is the cookie and the default challenge scheme is OpenID Connect, which is why UiAccess sends a browser to Pulse and does not accept a bearer.


Part 3: Machine authentication (ApiAccess)#

The credential#

Authorization: Bearer <pulse-issued-jwt>

ApiAccess pins the JWT bearer scheme and requires an authenticated user. No scope and no role are needed on the machine plane. Tokens are validated against Pulse's OIDC metadata and JWKS at {scheme}://{Pulse:Host}:{Pulse:Port}/auth.

A working read:

curl -s \
  -H "Authorization: Bearer $ATLAS_TOKEN" \
  -H "Accept: application/json" \
  "https://atlas.example.com:4552/api/v1/branches"

With a valid bearer this returns 200 application/json.

The audience requirement#

The token's aud claim must include Atlas's own Pulse:Auth:ClientId, which is Atlas by default. A valid Pulse token issued for another Timebase component is rejected with a 401.

So a client whose Pulse registration lists Historian as an audience needs Atlas added to that list before it can call Atlas. This is the same registration step Connector needs before its SignalR bearer clears the connector hub.

Validation parameters, all enforced:

Parameter Value Consequence
ValidateIssuer true The token must come from the configured Pulse IdP
ValidateAudience true, ValidAudience = Pulse:Auth:ClientId See above
ValidateLifetime true Expired tokens are rejected
ClockSkew TimeSpan.Zero No tolerance, so client and server clocks must agree. A host drifting by a few seconds produces intermittent 401s
ValidateIssuerSigningKey true Signature checked against Pulse's JWKS

Diagnosing a 401#

Atlas logs every bearer rejection under its bearer-authentication logger, as two distinct events:

  • A token was supplied and rejected. The log line carries the reason: bad signature, wrong audience, wrong issuer, expired, or malformed.
  • A challenge was issued. This fires on every 401 on this scheme, including when no token was sent. The line records whether an Authorization header arrived and whether it carried the Bearer prefix. Only the scheme word and the token length are logged, never the token value.

So "header ABSENT" means your client did not attach the token, and a reason string means the token arrived and was rejected on one of the rules above. Start there before changing your Pulse registration.

What else ApiAccess covers#

  • /i3x/v1 attaches the same policy, with one exception: GET /i3x/v1/info is anonymous, so that it can serve as a reachability probe.
  • /mcp attaches the same policy. An external Model Context Protocol client presents the same Pulse bearer, with the same audience.

An unauthenticated call may return a redirect#

An unauthenticated call to a machine-plane route can return 401 from the bearer handler or, on some deployments, a 302 to the Pulse sign-in page:

302 Found
Location: https://<pulse-host>:4542/auth/authorize?client_id=Atlas&redirect_uri=.../callback&response_type=code&scope=openid profile

A client that follows redirects then receives an HTML sign-in page. Write your client for both:

  • Always send a bearer. Do not rely on an unauthenticated call producing a parseable JSON error.
  • Disable redirect-following on API calls, so you see the status rather than a sign-in page.
  • Check Content-Type before parsing. text/html on the machine plane means a login redirect or the interface shell, never an API response.

Part 4: The browser plane (UiAccess and UiAdministratorAccess)#

/app is the route set the Atlas web interface uses for itself. Reads sit behind UiAccess; most mutations are admin-gated.

UiAccess pins no authentication scheme, so it resolves against the default scheme - the cookie - and challenges through OIDC. A bearer credential therefore does not clear the UI plane.

If you are inspecting Atlas's own network traffic while designing an integration, you will see /app/... calls rather than /api/v1/.... Build against the /api/v1 twin of the same resource: those are the published routes, they appear in the OpenAPI documents, and they accept your service credential.

The identity and login routes#

The /app identity group is mapped anonymous and enforces authorization inside each handler, so that an XHR receives a 401 it can act on rather than an OIDC redirect it cannot follow.

Route Behaviour
GET /app/me 200 with the identity envelope when signed in, or when auth is disabled. 401 when auth is on and the caller is unauthenticated
GET /app/_authcheck 204 under a valid session, or when auth is disabled. 401 otherwise. A stable smoke-test contract, kept separate from /app/me so its payload can grow
GET /app/login?returnUrl= Fires the Pulse OIDC challenge, then returns to returnUrl. Open-redirect guarded: absolute URLs and protocol-relative //host targets fall back to /
GET /auth/logout Signs out of both the OIDC and cookie schemes

The /app/me envelope is serialized camelCase:

{
  "userId": "<sub claim>",
  "displayName": "<name claim>",
  "isAdmin": false,
  "claims": [{ "type": "sub", "value": "..." }],
  "version": "1.4.x"
}

userId is the sub claim, falling back to the name identifier; displayName is the name claim, falling back to the identity name and then to userId.

Note

/auth/* serves /auth/access-denied and /auth/error, both anonymous HTML, plus the top-level /auth/logout.

401 versus 403, and the admin role#

Admin mutations on /app return:

  • allow when auth is disabled, or the caller is authenticated and in the admin role
  • 401 when auth is on and the caller is unauthenticated - sign in
  • 403 when the caller is authenticated but does not carry the role - request the role

The role is pulse:administrator, and the same string backs the UiAdministratorAccess policy. The built-in Bash, FileSystem and Script agent tools require it too.

Confirming the admin role reaches Atlas#

Admin status is derived from the role claim on the signed-in principal. After signing in, call GET /app/me in the browser and read isAdmin alongside the raw claims array, which is passed through as received precisely so you can see the claim types your Pulse deployment issues.

If isAdmin is false while claims shows the role, configure Pulse to issue the role in the claim type Atlas reads, then sign in again so a fresh session picks it up.


Part 5: Real-time surfaces (HubAccess)#

The three SignalR hubs accept either credential. HubAccess pins both the cookie scheme and the JWT bearer scheme:

Hub Route
Connector hub /hubs/connector
Value hub /hubs/v1/branches/{branch}/domains/{domain}/subscribe
Log hub /hubs/logs

Because the schemes are pinned, a failed handshake is answered with a plain 401 rather than a redirect, which is what a SignalR negotiate or WebSocket handshake needs.

A machine client attaches the bearer through SignalR's access-token provider rather than a header:

var connection = new HubConnectionBuilder()
    .WithUrl("https://atlas.example.com:4552/hubs/v1/branches/main/domains/PlantModel/subscribe",
        options => options.AccessTokenProvider = async () => await tokenProvider.GetTokenAsync())
    .Build();

The same audience rule applies: the calling client's Pulse registration must list Atlas's Pulse:Auth:ClientId as an audience.


Part 6: Configuration#

The keys#

Key Default Notes
Pulse:Auth:Enabled true Authentication is on in a shipped deployment
Pulse:Host localhost Startup reports an error if Host or Port is missing while auth is enabled
Pulse:Port 4542
Pulse:UseTls true Authority becomes http[s]://{Host}:{Port}/auth
Pulse:Auth:ClientId Atlas Also the required bearer audience
Pulse:Auth:ClientSecret "" Empty works for a Pulse client seeded with the grant

OIDC scopes are openid and profile, and the callback path is /callback. Atlas issues its own session cookie for the browser plane.

Note on config precedence: environment variables and command-line arguments are loaded after settings.config, so they take precedence over the file for Auth:* and everything else. In Docker there is no second settings file: the Docker overlay was composed into settings.config on the first start, so change Pulse:Host there, or set Pulse__Host in the Compose file.

What Pulse:Auth:Enabled=false changes#

Open mode is a development option. Four things change:

  1. Authentication is not registered, so no scheme is available.
  2. The gates are not attached. The machine plane is open because no policy is applied to it.
  3. /mcp is open, serving its read and skill tool set to any caller that can reach the port. Keep an open-mode Atlas off an untrusted network.
  4. The AI assistant is unavailable. The Gage-proxied agent transport requires an attributable, metered caller, so the readiness probe reports not-ready while auth is off.

GET /app/me also returns a local identity: userId: "local", displayName: "Local (authentication disabled)", isAdmin: true, and writes are attributed to Anonymous. See Part 7.


Part 7: Commit attribution#

Attribution comes from your credential. Atlas reads the principal's sub and name claims, which the cookie session and the bearer both carry:

Caller Git author
Authenticated user or machine client {name claim} <{sub claim}@atlas.local>
Authenticated with an id but no name Attributed to the id
Unauthenticated, or Pulse:Auth:Enabled=false Anonymous <anonymous@atlas.local>
Agent tool write The executing agent's name
Background or startup operation Atlas System <atlas-api@atlas.local>

The name and the id are sanitized to Git's identity rules: <, > and newlines are stripped from both, and whitespace and stray @ are additionally stripped from the email local part. The stable sub becomes the email local part, so attribution survives a display-name change.

To give an import pipeline its own identity in git log, register it as its own Pulse client and authenticate its requests.


esc
↑↓ navigate · ↵ open · ⌘K anywhere