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.
{ "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.comYourHALO_PUBLIC_URL, exactly, without a trailing slash. Every token'sissclaim 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 ishttp://localhostorhttp://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.
| Path | Method | What it does |
|---|---|---|
/.well-known/openid-configuration | GET | The discovery document. It lists the /oauth2 endpoints below. |
/oauth2/authorize | GET | Authorization endpoint, for the authorization code flow with PKCE (S256). |
/oauth2/token | POST | Authorization code, refresh token and client credentials grants, and the device code grant for the Halo CLI. |
/oauth2/userinfo | GET, POST | Claims about the person, with the access token as a bearer token. |
/oauth2/keys | GET | The public keys (JWKS) that verify ID tokens and JWT access tokens. |
/oauth2/introspect | POST | Token introspection, with the client secret over HTTP Basic. |
/oauth2/revoke | POST | Token revocation. |
/oauth2/logout | GET | End-session endpoint. With id_token_hint, it also ends the Halo session the token names. |
/oauth2/device_authorization | POST | Device authorization, for the built-in halo-cli client only. |
/saml/metadata | GET | SAML metadata. Its URL is also Halo's entity ID. |
/saml/sso | GET, POST | SAML SSO URL, for the HTTP-Redirect and HTTP-POST bindings. |
/saml/certificate | GET | The SAML signing certificate, as a PEM file. |
/saml/launch/{appId} | GET | Signs the person in to a SAML application from Halo, without a request from the application. |
/scim/v2 | GET, POST, PUT, PATCH, DELETE | The SCIM 2.0 server for users and groups, with an API key that has the scim scope. |
/api/v1/openapi.yaml | GET | The 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.
[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.
issuer = https://auth.example.comdiscovery = https://auth.example.com/.well-known/openid-configurationclient_id = hl_…client_secret = hls_…scopes = openid profile email groups offline_accessresponse_type = codepkce = S256signing_algorithm = RS256Most applications need only the discovery URL, the client ID and the client secret. offline_access adds a refresh token.
curl -s https://auth.example.com/oauth2/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials \ -d scope="billing:read"Halo returns a JWT access token whose sub is the client ID, with only the requested scopes that are registered on the application or granted to it on an API resource. Send resource=https://billing.example.com instead of scope to get every scope granted on that API.
curl -s https://auth.example.com/oauth2/introspect \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d token="$ACCESS_TOKEN"Introspection accepts the client secret over HTTP Basic only, and answers only the application the token was issued to.
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,
groupsincluded, so applications that read only the ID token get them. - Access token15 minutes
- Opaque by default: send it to
/oauth2/userinfoor/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
| Claim | Scope | Value |
|---|---|---|
sub | openid | The person's Halo id, such as usr_01J…. It never changes. |
email | The person's email address. | |
email_verified | true once the person redeemed a magic link, finished setup from an emailed link, or signed in through a provider that verified the address. | |
name | profile | The person's name. |
preferred_username | profile | The person's email address. |
groups | groups | The names, not ids, of every group the person belongs to, assigned and rule-based. |
amr | openid | Contains hwk after a passkey or security key, fed after an identity provider, and otp after an authenticator app, recovery code or magic link. |
sid | openid | The 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.
{ "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
- 01Verify the RS256 signature with the key from
/oauth2/keyswhosekidmatches the token header. - 02Check that
issequals yourHALO_PUBLIC_URLexactly. - 03Check that
audcontains the API's identifier, so tokens meant for anything else fail. - 04Check that
expis in the future, allowing a minute of clock skew. - 05Check that
scopecontains the scope the endpoint needs. ID tokens carry noscope, so this refuses them too. - 06Use
subas the caller andclient_idas 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.
persistentsends the Halo user ID, which never changes, andunspecifiedsends the email address. - Attributes
email,name,given_name,family_nameandgroups, 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.
{ "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.
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.
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
scimscope, from a service account with the user administrator role. - Resources
/Usersand/Groupswith GET, POST, PUT, PATCH and DELETE, plus/ServiceProviderConfig,/ResourceTypesand/Schemas.- Filters
- A single
eqcondition:userName,emails.valueorexternalIdfor users,displayNameorexternalIdfor groups. - Paging
startIndexandcount, 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
userNameand updates it instead. PATCH /Users/{id}- Sets
activeto 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
apiscope. Keys start withhlk_, 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/.
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.
{ "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
- 1Read the raw body before parsing it. Re-encoding the JSON changes the bytes and breaks the signature.
- 2Split the header on
,and each part on=. - 3Reject the request when
tis more than 5 minutes away from your clock. - 4Compute the HMAC-SHA256 of
t + "." + body, keyed with the whole secret includingwhsec_, and compare it withv1in constant time. - 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.
Content-Type: application/jsonUser-Agent: Halo-WebhooksHalo-Signature: t=<unix time>,v1=<hex HMAC-SHA256>{ "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.
| Command | Runs on | What it does |
|---|---|---|
halo serve | Server | Run migrations, then serve the API and the OpenID Connect endpoints. |
halo migrate | Server | Apply database migrations and exit. |
halo bootstrap --email E --name N | Server | Create the first global administrator and print their setup link. |
halo rotate-secret-key | Server | Re-encrypt stored secrets from HALO_SECRET_KEY to HALO_NEW_SECRET_KEY. Stop Halo first. |
halo seed-demo | Development | Load the Fernway Systems demo organization. Needs HALO_DEV=1. |
halo dev-session --email E | Development | Print a session cookie for a user. Needs HALO_DEV=1. |
halo login --server URL | Your computer | Sign in to a Halo server with the device flow. |
halo whoami | Your computer | Show your name, email address, user id, groups and server. |
halo ssh-cert [--key K] [--out F] | Your computer | Get a short-lived SSH certificate for a public key, ~/.ssh/id_ed25519.pub by default. |
halo logout | Your computer | Revoke 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