Skip to content
Halo

Security

How Halo protects the front door.

Halo decides who can sign in to everything else, so its security model is documented in full, including what is not finished yet. Halo has not had an independent security audit.

At a glance

Independent security audit
None yet
Certification
None yet
Supported version
Latest commit on main
Vulnerability reports
Private, through GitHub
Source code
Public, Apache-2.0

Disclosure

Report a vulnerability privately.

Use GitHub's private vulnerability reporting. Only you and the maintainers can read the report, and you receive updates in it until the advisory is published.

Do not report a vulnerability in a public issue, pull request, discussion or chat. If you are unsure whether something is a security problem, report it privately anyway.

What to include

  • The affected part: a Go package such as internal/auth, a web route such as /sign-in, or the deployment files in deploy/.
  • The commit you tested.
  • Steps to reproduce against a local instance, or a proof of concept. The demo organization from halo seed-demo is a good test bed.
  • The impact: what an attacker gains, and what they need first, such as an account, an administrator role or a network position.
  • Relevant configuration, such as whether HALO_DEV was set or which reverse proxy sits in front of Halo.
  • Whether you would like to be credited, and under which name.

Test only against instances you run yourself, and do not access or change other people's data.

What happens next

Response targets
StepTarget
Acknowledge the report3 working days
Confirm or rule out the vulnerability, with a severity10 working days
Fix or mitigate critical and high severity issues on main30 days
Publish an advisory and credit the reporterWhen the fix is on main, and no later than 90 days after the report unless agreed otherwise

The maintainers' targets, counted from the day the report arrives.

Supported versions

Supported versions
VersionSupported
Latest mainYes
Anything olderNo

Halo is in early development and has not published a release. Until version 1.0, only the latest commit on main receives security fixes. Older commits and forks are not patched.

In brief

The short version.

The sections below condense the security model, which describes the code on main as it is today, including its known limitations.

Full security model
Hashed
Session tokens, client secrets, API keys and refresh tokens are stored only as SHA-256 hashes and compared in constant time.
Sealed
Signing keys, authenticator seeds, provider secrets and the SSH CA are encrypted with AES-256-GCM. The key can be rotated.
Audited
Every administrative change checks the actor's role and writes an audit event in the same database transaction.
Same-origin
Requests that change state must come from Halo's own pages. Cross-site requests are refused.
HTTPS only
Halo refuses to start on plain HTTP outside development, and session cookies are Secure, HttpOnly and SameSite=Lax.
Disclosed
Vulnerabilities are reported privately, with response targets written down in the security policy.

Authentication

No passwords to steal.

People sign in with a passkey or security key, an authenticator app or recovery code, an account at an identity provider, or a magic link when email is configured. Security administrators can turn each method off, and a sign-in with a method that is off is refused.

How sign-in works

Passkeys and security keys

  • Bound to the hostname of HALO_PUBLIC_URL, the WebAuthn relying party ID. Halo accepts responses only from that origin.
  • Discoverable credentials, so nobody types an email address. Halo asks the authenticator for user verification where it supports one.
  • Halo stores the public key, credential id, signature counter, transports and backup flags. It never receives a private key or biometric data.
  • A signature counter that goes backwards suggests a cloned credential, and Halo rejects the sign-in.
  • Each registration or sign-in challenge works once and expires after 5 minutes.

Authenticator apps

  • Six-digit time-based codes, SHA-1 with 30-second steps. Halo accepts the codes either side of the current one, for clock drift.
  • Each code works once: Halo refuses a code from the last step used or earlier.
  • The secret shared with the app is encrypted with HALO_SECRET_KEY.

Recovery codes

  • Ten at a time, shown once, each 16 random base32 characters (80 bits).
  • Stored as HMAC-SHA256 with a key derived from HALO_SECRET_KEY, so guesses cannot be checked against the stored values without the key.
  • Each code works once, and generating new codes replaces every old one.

Magic links

  • Sent only to active accounts while magic links are on, with the same answer whether or not the account exists.
  • A link works once and expires after 10 minutes. Halo sends at most 5 per person in 15 minutes and stores only each link's SHA-256 hash.
  • A link proves access to a mailbox and nothing more, so a policy that requires a passkey or security key refuses it.

Lockout

  • After 5 failed authenticator or recovery codes within 15 minutes, Halo refuses further codes for that account with 429 ERR_TOO_MANY_ATTEMPTS.
  • Security administrators set the threshold from 3 to 20 and the window from 5 minutes to 24 hours. The count also applies to addresses with no account.
  • Passkey sign-in is not affected.

Sessions

Sessions are checked on the server, on every request.

Signing out, revoking a session, suspending the account, resetting its authentication or blocking the device a session started on ends it immediately, and revokes the access and refresh tokens applications received during it.

Cookies Halo sets
CookieContentsAttributes
halo_session32 random bytes. Halo stores only the SHA-256 hash.HttpOnly, SameSite=Lax, Secure, path /, expires after the session lifetime (12 hours by default)
halo_device32 random bytes that identify the browser to the access policy engine. It does not authenticate anyone.HttpOnly, SameSite=Lax, Secure, path /, expires after 2 years
halo_federationThe state of a federated sign-in in progress.HttpOnly, SameSite=Lax, Secure, path /api/v1/auth/federated, expires after 10 minutes

HALO_DEV=1 removes the Secure flag from these cookies, for local development over http.

Same-origin protection

Every request to /api/ with a method other than GET, HEAD or OPTIONS goes through this check before any handler runs. Browsers send these headers themselves, so a page on another site cannot make a signed-in browser change anything in Halo.

curl and other scripts send neither header. They pass the check and are authenticated by the session cookie, API key or token they present.

  1. 1With a Sec-Fetch-Site header, the request passes only when the value is same-origin or none.
  2. 2Without one, it passes only when there is no Origin header, or the Origin equals the origin of HALO_PUBLIC_URL.
  3. Anything else is refused403 ERR_CROSS_SITE

Secrets at rest

Hashed where possible, encrypted where not.

Values Halo only needs to compare are stored as hashes. Values it must read back are encrypted under HALO_SECRET_KEY, which halo rotate-secret-key replaces in a single transaction.

SHA-256 hash

Compared in constant time

  • Session tokens
  • Client secrets, shown once
  • Refresh tokens
  • Invite and reset link tokens
  • Magic link tokens
  • API keys
  • Federated sign-in state

HMAC-SHA256

With a key derived from HALO_SECRET_KEY

  • Recovery codes

AES-256-GCM

Encrypted under HALO_SECRET_KEY

  • OpenID Connect and SAML signing keys
  • Authenticator-app secrets
  • Tokens for provisioning applications over SCIM
  • Identity provider client secrets
  • Webhook signing secrets
  • The SSH certificate authority key
  • Email waiting in the outbox

Not stored

Encrypted ids or signed tokens

  • Opaque access tokens: the token id, encrypted with a key derived from HALO_SECRET_KEY
  • Authorization codes: the sign-in request id, encrypted with the same derived key
  • JWT access tokens: signed, with only the token id kept to check revocation

Passkey public keys are stored as they are, because they are public. Losing HALO_SECRET_KEY makes the signing keys and authenticator-app secrets unreadable, and a backup is only usable with the key that was in use when it was taken.

Tokens and keys

Credentials for machines, and tokens for applications.

API keys and service accounts

  • API keys belong to service accounts, start with hlk_ followed by 32 random bytes, and are shown once. Halo stores only their SHA-256 hash.
  • A key carries scopes: api for the management API and scim for the SCIM server. Without the matching scope it counts as no credentials, and keys are ignored on the /api/v1/me and /api/v1/auth routes.
  • A request made with a key has exactly the roles of its service account. Administrators can give a service account only roles they hold.
  • A key stops working when it is revoked, expires, or its service account is disabled.
  • The API refuses request bodies over 1 MiB, invalid JSON, and JSON with fields it does not expect.

OpenID Connect

  • Sign-in requests must use the authorization code flow. PKCE accepts S256 only, and single-page and native applications must send a challenge.
  • Redirect URIs must exactly match a registered address: https without a fragment, except http://localhost and http://127.0.0.1.
  • Sign-in requests expire after 15 minutes and codes work once. A completed sign-in can only be redeemed by the browser session that completed it.
  • Refresh tokens are issued only for offline_access. They rotate on every use unless rotation is turned off for the application, and a used refresh token is rejected.
  • ID tokens are signed with RS256 and a 2048-bit key. After a rotation, previous keys stay in /oauth2/keys for 7 days.
  • Halo checks that the person is still active and assigned to the application whenever it issues or refreshes tokens, or answers userinfo and introspection. Disabling an application revokes its tokens.

Administration

Every change is checked and written down.

Six roles decide who may administer Halo, and every administrative change is recorded with who made it and from which address.

  • Every administrative endpoint checks the actor's role. The six roles run from global administrator to auditor, who can only read.
  • Only a global administrator can change an account that holds an administrator role. Nobody can suspend their own account, and the last active global administrator cannot be suspended.
  • Every administrative change, and every change people make to their own sign-in methods, writes an audit event with the actor and IP address in the same transaction as the change.
  • Nobody can decide their own access request, global administrators included.
  • halo bootstrap creates a global administrator only when none exists, and halo seed-demo refuses to run unless HALO_DEV=1.
The audit log in the Halo console, listing administrative changes with their time, actor, action such as user.suspend or policy.create, summary, target and IP address

Webhooks, SSH and transport

Signed deliveries, certificates that expire, HTTPS only.

Webhooks

  • Every delivery is signed with HMAC-SHA256 over a timestamp and the raw body, with a secret per endpoint that is shown once.
  • The Halo-Signature header carries the timestamp and the signature. Receivers should reject timestamps older than 5 minutes.
  • Endpoints must use https, except loopback addresses. Halo does not follow redirects and gives up on a request after 10 seconds.
  • Bodies include email and IP addresses, so send them only to systems allowed to hold that data.

SSH certificates

  • The certificate authority's Ed25519 key is encrypted with HALO_SECRET_KEY. Its public key is at /api/v1/ssh/ca.pub.
  • Halo signs a key only for a signed-in person, only for the principals their groups map to, and for at most 24 hours, 8 by default.
  • Each certificate gets a unique serial and writes an audit event.
  • sshd cannot check whether a certificate was revoked, so it stays valid until it expires, even after the person is suspended. Keep the lifetime short.

Transport and deployment

  • Halo refuses to start when HALO_PUBLIC_URL does not use https, unless HALO_DEV=1.
  • Only the web interface needs to be reachable from outside. Keep the Go server's port private.
  • Halo reads X-Forwarded-For only from proxies in HALO_TRUSTED_PROXIES, so a client cannot forge its own address.
  • Unexpected errors are answered with ERR_INTERNAL and no internal details. The request log leaves out query strings, headers and bodies.

Known limitations

What Halo does not do yet.

Listed in the security model and the README, and repeated here so you can weigh them before you deploy.

  1. 01Halo has not had an independent security audit or OpenID Connect conformance testing.
  2. 02Rotating HALO_SECRET_KEY removes every recovery code, and opaque access tokens and unredeemed authorization codes stop working.
  3. 03Halo sends no separate verification email. An address stays unverified until the person redeems a magic link, finishes setup from a link Halo emailed, or signs in through an identity provider that verified it.
  4. 04The code lockout is per account, so anyone who knows an address can trigger it. It blocks that person's authenticator and recovery codes, not their passkeys, for as long as the attempts continue.
  5. 05APIs that validate JWT access tokens themselves do not see revocations until the token expires.
  6. 06SSH certificates cannot be revoked by Halo.
  7. 07Webhook signing secrets cannot be rotated. Replace the endpoint instead.

Found a security problem?

Report it privately through GitHub, never in a public issue. The maintainers aim to acknowledge a report within 3 working days.