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 indeploy/. - The commit you tested.
- Steps to reproduce against a local instance, or a proof of concept. The demo organization from
halo seed-demois 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_DEVwas 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
| Step | Target |
|---|---|
| Acknowledge the report | 3 working days |
| Confirm or rule out the vulnerability, with a severity | 10 working days |
| Fix or mitigate critical and high severity issues on main | 30 days |
| Publish an advisory and credit the reporter | When 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
| Version | Supported |
|---|---|
| Latest main | Yes |
| Anything older | No |
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.
- 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.
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.
| Cookie | Contents | Attributes |
|---|---|---|
halo_session | 32 random bytes. Halo stores only the SHA-256 hash. | HttpOnly, SameSite=Lax, Secure, path /, expires after the session lifetime (12 hours by default) |
halo_device | 32 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_federation | The 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.
- 1With a
Sec-Fetch-Siteheader, the request passes only when the value issame-originornone. - 2Without one, it passes only when there is no
Originheader, or theOriginequals the origin ofHALO_PUBLIC_URL. - 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:
apifor the management API andscimfor the SCIM server. Without the matching scope it counts as no credentials, and keys are ignored on the/api/v1/meand/api/v1/authroutes. - 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://localhostandhttp://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/keysfor 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 bootstrapcreates a global administrator only when none exists, andhalo seed-demorefuses to run unlessHALO_DEV=1.

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-Signatureheader 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_URLdoes not use https, unlessHALO_DEV=1. - Only the web interface needs to be reachable from outside. Keep the Go server's port private.
- Halo reads
X-Forwarded-Foronly from proxies inHALO_TRUSTED_PROXIES, so a client cannot forge its own address. - Unexpected errors are answered with
ERR_INTERNALand 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.
- 01Halo has not had an independent security audit or OpenID Connect conformance testing.
- 02Rotating
HALO_SECRET_KEYremoves every recovery code, and opaque access tokens and unredeemed authorization codes stop working. - 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.
- 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.
- 05APIs that validate JWT access tokens themselves do not see revocations until the token expires.
- 06SSH certificates cannot be revoked by Halo.
- 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.