Skip to content
Halo

Developers

Standard protocols, no SDK to install.

Halo is an OpenID Connect provider, a SAML 2.0 identity provider and a SCIM 2.0 server. Applications connect through those standards with the libraries they already use, and anything the console does, a script can do through the REST API.

/.well-known/openid-configuration
{  "issuer": "https://auth.example.com",  "authorization_endpoint": "https://auth.example.com/oauth2/authorize",  "token_endpoint": "https://auth.example.com/oauth2/token",  "userinfo_endpoint": "https://auth.example.com/oauth2/userinfo",  "jwks_uri": "https://auth.example.com/oauth2/keys",  "end_session_endpoint": "https://auth.example.com/oauth2/logout",  "device_authorization_endpoint": "https://auth.example.com/oauth2/device_authorization",  "introspection_endpoint": "https://auth.example.com/oauth2/introspect",  "revocation_endpoint": "https://auth.example.com/oauth2/revoke",  "response_types_supported": ["code"],  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code"],  "scopes_supported": ["openid", "profile", "email", "offline_access", "groups"],  "code_challenge_methods_supported": ["S256"],  "id_token_signing_alg_values_supported": ["RS256"]}

An excerpt of Halo's discovery document. Clients that support discovery read every endpoint from it.

Register an app

Register once, copy four values.

In the console, open Applications, choose Add application, then OpenID Connect and the kind of application. Halo shows what your application needs, and shows the client secret only once.

Animation: registering Grafana in Halo. Choose OpenID Connect, enter the name and redirect URI, copy the client ID, client secret and issuer, then assign the Engineering group so its members can sign in.

Issuer
https://auth.example.comYour HALO_PUBLIC_URL, exactly, without a trailing slash. Every token's iss claim carries it.
Client ID
hl_…Identifies the application. It is not a secret.
Client secret
hls_…Web applications and services only, shown once. Single-page and native apps get none and must use PKCE with S256.
Redirect URI
https://app.example.com/callbackWhere Halo sends people back. It must match a registered address exactly, and use https unless it is http://localhost or http://127.0.0.1.

Applications that support discovery read every endpoint from /.well-known/openid-configuration on the issuer, so most need only that URL, the client ID and the secret. Then assign a group under Users & groups: until one is assigned, nobody can sign in.

Endpoints

Every path, relative to your issuer.

OpenID Connect clients find their endpoints in the discovery document. SAML applications import the metadata URL, and SCIM clients take the base URL.

API guide
Halo's protocol endpoints
PathMethodWhat it does
/.well-known/openid-configurationGETThe discovery document. It lists the /oauth2 endpoints below.
/oauth2/authorizeGETAuthorization endpoint, for the authorization code flow with PKCE (S256).
/oauth2/tokenPOSTAuthorization code, refresh token and client credentials grants, and the device code grant for the Halo CLI.
/oauth2/userinfoGET, POSTClaims about the person, with the access token as a bearer token.
/oauth2/keysGETThe public keys (JWKS) that verify ID tokens and JWT access tokens.
/oauth2/introspectPOSTToken introspection, with the client secret over HTTP Basic.
/oauth2/revokePOSTToken revocation.
/oauth2/logoutGETEnd-session endpoint. With id_token_hint, it also ends the Halo session the token names.
/oauth2/device_authorizationPOSTDevice authorization, for the built-in halo-cli client only.
/saml/metadataGETSAML metadata. Its URL is also Halo's entity ID.
/saml/ssoGET, POSTSAML SSO URL, for the HTTP-Redirect and HTTP-POST bindings.
/saml/certificateGETThe SAML signing certificate, as a PEM file.
/saml/launch/{appId}GETSigns the person in to a SAML application from Halo, without a request from the application.
/scim/v2GET, POST, PUT, PATCH, DELETEThe SCIM 2.0 server for users and groups, with an API key that has the scim scope.
/api/v1/openapi.yamlGETThe OpenAPI 3.1 document for the REST API. No authentication.

Examples

Configuration you can paste.

Every example uses https://auth.example.com as Halo's address. Each application page in the console shows its own setup guide with your values filled in.

Complete applications

  • examples/go-web-clientAn 80-line Go web app that signs in over OpenID Connect with the authorization code flow and PKCE, and shows the claims it receives.
  • examples/go-saml-spA SAML service provider that starts sign-in at Halo, checks the signed assertion against Halo's metadata, and shows the NameID, groups and attributes.
grafana.ini
[auth.generic_oauth]enabled = truename = Haloallow_sign_up = trueclient_id = hl_…client_secret = $__file{/etc/grafana/halo-client-secret}scopes = openid profile email groupsauth_url = https://auth.example.com/oauth2/authorizetoken_url = https://auth.example.com/oauth2/tokenapi_url = https://auth.example.com/oauth2/userinfouse_pkce = truegroups_attribute_path = groupsrole_attribute_path = contains(groups[*], 'Grafana editors') && 'Editor' || 'Viewer'

Members of the Halo group Grafana editors become editors and everyone else viewers. $__file{…} reads the client secret from a file instead of keeping it in grafana.ini.

Tokens and claims

What Halo signs, and what it puts inside.

ID tokens are signed with RS256 using a 2048-bit RSA key. When a global administrator rotates it, the new key signs from then on, and previous keys stay in /oauth2/keys for 7 days so the tokens they signed still verify.

Default lifetimes

ID token15 minutes
Signed with RS256. Carries the claims of the requested scopes, groups included, so applications that read only the ID token get them.
Access token15 minutes
Opaque by default: send it to /oauth2/userinfo or /oauth2/introspect. Service applications, the Halo CLI and applications granted scopes on an API resource receive a JWT.
Refresh token8 hours
Only with offline_access. Each use returns a new one and the old one stops working, unless rotation is off for the application.

Lifetimes and rotation are set per application. Halo checks that the person is still active and assigned on every token, refresh, userinfo and introspection request.

Claims

Claims in ID tokens and userinfo
ClaimScopeValue
subopenidThe person's Halo id, such as usr_01J…. It never changes.
emailemailThe person's email address.
email_verifiedemailtrue once the person redeemed a magic link, finished setup from an emailed link, or signed in through a provider that verified the address.
nameprofileThe person's name.
preferred_usernameprofileThe person's email address.
groupsgroupsThe names, not ids, of every group the person belongs to, assigned and rule-based.
amropenidContains hwk after a passkey or security key, fed after an identity provider, and otp after an authenticator app, recovery code or magic link.
sidopenidThe Halo session, in ID tokens.

API resources

Protect your own APIs with Halo tokens.

An API resource names one of your APIs by an identifier, such as https://billing.example.com, with its scopes. Applications you grant those scopes receive JWT access tokens with the identifier in aud, and the API checks them itself, without calling Halo on each request.

Ask for API scopes by name, as in scope=openid billing:read, or by resource, as in resource=https://billing.example.com (RFC 8707), which adds every scope the application was granted on that API.

Halo honours resource on authorization, device authorization and client credentials requests only. A resource the application has no grant for fails with invalid_target.

Access token payload
{  "iss": "https://auth.example.com",  "sub": "usr_01J9Z8Q4M2N5P7R9T1V3X5Z7B9",  "aud": ["https://billing.example.com"],  "exp": 1791101700,  "iat": 1791100800,  "nbf": 1791100800,  "jti": "atk_01JA2X5Q6R7S8T9V0W1X2Y3Z4A",  "client_id": "hl_6Wq1…",  "scope": "openid billing:read"}

The token carries no email address, name or groups. If the API needs them, it calls /oauth2/userinfo with the token.

Validate a token in your API

  1. 01Verify the RS256 signature with the key from /oauth2/keys whose kid matches the token header.
  2. 02Check that iss equals your HALO_PUBLIC_URL exactly.
  3. 03Check that aud contains the API's identifier, so tokens meant for anything else fail.
  4. 04Check that exp is in the future, allowing a minute of clock skew.
  5. 05Check that scope contains the scope the endpoint needs. ID tokens carry no scope, so this refuses them too.
  6. 06Use sub as the caller and client_id as the application acting for them.

A token that passes these checks stays valid until it expires, even after Halo revokes it, for example when the person is suspended. Keep the API's token lifetime short.

SAML 2.0

For applications that only speak SAML.

Halo is a SAML 2.0 identity provider for web browser sign-in, started by the application or from Halo, over the HTTP-Redirect and HTTP-POST bindings. When an application supports both protocols, prefer OpenID Connect.

NameID
The email address by default. persistent sends the Halo user ID, which never changes, and unspecified sends the email address.
Attributes
email, name, given_name, family_name and groups, renamed per application to the names it expects.
Signing
RSA-SHA256 with a 2048-bit key. Halo signs the assertion, and the whole response as well when the application expects it.
Not supported
Single logout, encrypted assertions, verifying signatures on authentication requests, ForceAuthn and IsPassive.
POST /api/v1/applications
{  "name": "Team wiki",  "protocol": "saml",  "saml": {    "entityId": "https://wiki.example.com/saml/metadata",    "acsUrls": ["https://wiki.example.com/saml/acs"],    "nameIdFormat": "email"  }}

Creates a SAML application. Send metadataXml instead of entityId and acsUrls to have Halo read them from the service provider's metadata.

SAML guide

SCIM 2.0

Provisioning in both directions.

Halo is a SCIM server for directories that provision people into it, and a SCIM client that provisions people into your applications.

Provisioning guide

Into Halo

For Okta, Microsoft Entra ID or an HR system. Tested with the request formats of Okta and Entra ID.

Base URL
https://auth.example.com/scim/v2
Token
An API key with the scim scope, from a service account with the user administrator role.
Resources
/Users and /Groups with GET, POST, PUT, PATCH and DELETE, plus /ServiceProviderConfig, /ResourceTypes and /Schemas.
Filters
A single eq condition: userName, emails.value or externalId for users, displayName or externalId for groups.
Paging
startIndex and count, at most 200 results per page.
Not supported
Bulk operations, sorting, ETags and password changes.

Out to your applications

For applications with a SCIM 2.0 endpoint, every 2 minutes or when an administrator chooses Sync now.

POST /Users
Creates each active person assigned to the application through a group.
PUT /Users/{id}
Updates them when their name, email address, title or department changes.
GET /Users?filter
When a create answers 409 Conflict, finds the existing account by userName and updates it instead.
PATCH /Users/{id}
Sets active to false when they lose the assignment or stop being active.

Halo never deletes accounts in the application. Outbound SCIM pushes users only: groups are not pushed yet.

REST API

The API the console is built on.

The console and the account portal use Halo's JSON API for everything, so anything they do, a script can do. The OpenAPI 3.1 document at /api/v1/openapi.yaml lists every route with its roles, schemas and error codes.

API key
For automation. Create a service account with the roles it needs, then a key with the api scope. Keys start with hlk_, are shown once, and act with exactly the service account's roles.
Session cookie
halo_session, to call the API as yourself from a script.
Halo CLI token
The access token from halo login, on the personal routes under /api/v1/me/.
Invite a person
curl -X POST https://auth.example.com/api/v1/users \  -H "Authorization: Bearer $HALO_API_KEY" \  -H "Content-Type: application/json" \  -d '{"email": "sam@example.com", "name": "Sam Lee", "department": "Engineering"}'

Returns the new user, a one-time enrollUrl with its expiresAt, and emailed, which says whether Halo sent it already. The key's service account needs the user administrator role.

Every error
{  "error": {    "code": "ERR_NOT_FOUND",    "message": "This user was not found. It may have been deleted."  }}

Match on code, which is stable, and show message to people.

Webhooks

Signed events, posted as they happen.

Halo sends audit and sign-in events to your HTTPS endpoint, so a SIEM, a chat channel or an internal tool can react to them. Each endpoint has its own signing secret, which starts with whsec_ and is shown once.

Verify a delivery

  1. 1Read the raw body before parsing it. Re-encoding the JSON changes the bytes and breaks the signature.
  2. 2Split the header on , and each part on =.
  3. 3Reject the request when t is more than 5 minutes away from your clock.
  4. 4Compute the HMAC-SHA256 of t + "." + body, keyed with the whole secret including whsec_, and compare it with v1 in constant time.
  5. 5Answer with a 2xx status within 10 seconds, and do slow work afterwards.

A failed delivery is retried after 30 seconds, then after 1, 2, 4, 8, 16 and 32 minutes, and marked failed after 8 attempts. Halo does not follow redirects. Order events by time and skip any id you already processed.

Webhooks guide, with a Go receiver
Delivery headers
Content-Type: application/jsonUser-Agent: Halo-WebhooksHalo-Signature: t=<unix time>,v1=<hex HMAC-SHA256>
Event body
{  "id": "aud_01JA2X5Q6R7S8T9V0W1X2Y3Z4A",  "type": "user.invite",  "time": "2026-10-04T10:00:10.123456Z",  "data": {    "id": "aud_01JA2X5Q6R7S8T9V0W1X2Y3Z4A",    "time": "2026-10-04T10:00:10.123456+00:00",    "actorId": "usr_01J9Z8Q4M2N5P7R9T1V3X5Z7B9",    "action": "user.invite",    "summary": "Invited Sam Lee <sam@example.com>",    "targetType": "user",    "targetId": "usr_01JA2X5Q6R7S8T9V0W1X2Y3Z4B",    "target": "Sam Lee",    "ip": "203.0.113.7"  }}

Command line

One binary for the server and the client.

halo runs the server, and on people's own computers it signs in with the device flow through the built-in halo-cli application. Build it with go build ./cmd/halo.

halo commands
CommandRuns onWhat it does
halo serveServerRun migrations, then serve the API and the OpenID Connect endpoints.
halo migrateServerApply database migrations and exit.
halo bootstrap --email E --name NServerCreate the first global administrator and print their setup link.
halo rotate-secret-keyServerRe-encrypt stored secrets from HALO_SECRET_KEY to HALO_NEW_SECRET_KEY. Stop Halo first.
halo seed-demoDevelopmentLoad the Fernway Systems demo organization. Needs HALO_DEV=1.
halo dev-session --email EDevelopmentPrint a session cookie for a user. Needs HALO_DEV=1.
halo login --server URLYour computerSign in to a Halo server with the device flow.
halo whoamiYour computerShow your name, email address, user id, groups and server.
halo ssh-cert [--key K] [--out F]Your computerGet a short-lived SSH certificate for a public key, ~/.ssh/id_ed25519.pub by default.
halo logoutYour computerRevoke the refresh token at Halo and delete the stored credentials.

halo login stores the server address and its tokens in credentials.json under your user configuration directory, readable only by you, and refreshes the access token on its own.

Run Halo on your own infrastructure.

One command on a Linux host with Docker. Then create your administrator and register a passkey.

curl -fsSL https://halo.scala.gg/install.sh | sh