Getting started
lanyard is a single binary that is both the API server and the CLI client. Install it with Homebrew or curl, then start the server — or run the full stack (PostgreSQL + lanyard) with docker compose up, no toolchain needed.
$ brew install EmiraLabs/lanyard/lanyard
# any Unix — one-shot install script (puts lanyard on your PATH)
$ curl -fsSL https://github.com/EmiraLabs/lanyard/releases/latest/download/install.sh | sh
→ > lanyard --help
$ docker run --rm -p 8080:8080 emiralabs/lanyard:latest serve
→ lanyard on :8080
# for the bundled profile with Postgres, clone the repo and use docker compose up
# secrets come from .env at runtime and fail fast when missing — the server never starts half-configured
$ cp policy.example.yaml policy.yaml
# credentials are never written into policy.yaml directly —
# each token_ref points at an env var, resolved at startup
$ export LANYARD_TOKEN_ALICE="..."
$ ./bin/lanyard serve
→ listening on :8080
$ lanyard login alice@example.com
Password: **** → session saved locally
$ lanyard request --entitlement github:acme:repo/acme/api:push \
--reason "debug INC-4821" --for 1h
✓ requested · g_8f3a · active (auto-approved)
/console and the requester portal at /portal. Open http://localhost:8080 after lanyard serve to see grants, approvals, reconciliation and the audit trail in a browser.How a grant moves
Every access grant follows the same lifecycle, whichever door it came through — CLI, API, or console.
A request is created pending. The policy engine either auto-approves it or leaves it for a human with the approve verb. Once approved it becomes active for its requested duration, then the reconciler transitions it to expired on its own — or a revoker ends it early, moving it straight to revoked. Both are terminal; a grant never outlives its expires_at while still reporting itself active.
Entitlements
The unit of access is always four segments — provider:instance:resource:capability, e.g. github:acme:repo/acme/api:push. There is no shorthand: instance names which org, account or cluster, so a broker fronting two of them can never resolve a grant ambiguously.
RBAC
Identities and roles are loaded from the policy file. A role is a list of (verb, entitlement) pairs — verb is one of request, approve, revoke — plus optional role_management / role_view authority over non-entitlement resources.
Policy & approval chains
max_duration caps every request and auto_approve skips the human step. Anything else runs an ordered approval_chain: levels are AND, the roles inside one level are OR, and a level can be satisfied by an active delegation.
Reconciler
Runs on LANYARD_RECONCILE_INTERVAL, comparing desired state against the provider, repairing drift, and expiring grants on time even across a restart. Each sweep is recorded durably, so a stopped sweep never reads as a clean one.
GET /audit (verify it with GET /audit/verify). On any internal error the system fails closed — deny, never grant.CLI
Talks to LANYARD_SERVER (default http://localhost:8080) and authenticates with LANYARD_TOKEN, or the session saved by login.
| Command | Flags | Does |
|---|---|---|
| serve | — | Runs the API server (and the console and portal, from the same binary). |
| init, install | — | Interactive first-run setup wizard. |
| login <actor> | — | Prompts for a password (hidden input), saves the session token locally. |
| logout | — | Revokes the current session and clears the saved credential. |
| enroll <actor> | — | Issues a single-use token so that actor can set their own password. |
| passwd | --token T | Redeems an enrollment token and sets your password. |
| request | --entitlement E --reason R --for D | Requests entitlement E for duration D (e.g. 1h, 30m). All three flags are required. |
| list | [--status S] | Lists grants visible to you, optionally filtered by pending|active|expired|revoked. |
| get <id> | — | Shows one grant's full detail. |
| approve <id> | — | Approves a pending grant. Requires the approve verb on that entitlement, and satisfies one chain level. |
| revoke <id> | [--reason R] | Ends an active or pending grant early. |
| integration add <provider> | --name N --config-file F | Registers a vendor integration from a JSON config file. |
| audit verify | — | Verifies the audit hash chain against the database directly, not through the API. Exits 0 when intact and 1 on tampering or error, so it can gate a cron job or CI. |
| db:migrate | — | Applies pending schema migrations. |
| update, upgrade | — | Upgrades the installed lanyard binary. |
db:migrate moves the database forward; update moves the binary. Run db:migrate after an upgrade that ships a migration — and never edit a migration that has already been applied.HTTP API
The CLI speaks to lanyard over the same REST surface you can. The full reference — every endpoint, method, request/response schema, status code, and copy-pasteable example — lives on a dedicated page.
In short: every endpoint but /auth/login expects Authorization: Bearer <token>, identity always comes from the resolved token (never the body), unknown fields are rejected with 400, and internal errors return a generic {"error":"internal error"} — full detail is logged server-side, never leaked.
Configuration
Configuration comes from environment variables, optionally layered over a YAML config file. Every value is bounded — there is no setting that can be widened to "forever".
| Variable | Default | Purpose |
|---|---|---|
| LANYARD_ADDR | :8080 | Listen address for serve. |
| LANYARD_CONFIG_FILE | ~/.config/lanyard/config.yaml | YAML config file. Environment variables win over anything set in it. |
| LANYARD_POLICY_FILE | policy.yaml | Identities, roles, and policy rules. |
| LANYARD_MODE | self-hosted | Deployment mode, self-hosted or cloud. Decides whether lanyard may hold a reference to a long-lived credential it does not own (ADR 0009). Closed to those two values — an unrecognised mode is a startup error, never a silent fallback. |
| LANYARD_DB_DRIVER | sqlite | Storage backend: memory, sqlite, or postgres. |
| LANYARD_DB_DSN | lanyard.db | Database DSN (required for postgres). Treated as a credential and never logged. |
| LANYARD_RECONCILE_INTERVAL | 1m | How often the reconciler checks for drift/expiry. Capped at 24h — the sweep is what enforces expiry, so a long interval widens the window where expired access stays live. |
| LANYARD_SESSION_TTL | 12h | Lifetime of login session tokens. Capped at one year. |
| LANYARD_SECRET_MANAGER | env | Secret backend that resolves every *_ref. env is the only one implemented today. |
| LANYARD_NOTIFY_WEBHOOK | — | Secret reference to an incoming-webhook URL, e.g. env://SLACK_WEBHOOK_URL. Unset means notifications are off. See Approvals. |
| LANYARD_LOG_LEVEL | info | One of debug, info, warn, error. |
| LANYARD_TRUSTED_PROXY_HOPS | 0 | How many reverse proxies sit in front of lanyard. Decides how far back in X-Forwarded-For the client IP is read; 0 means the header is not trusted at all. |
| LANYARD_CORS_ORIGINS | — | Optional CORS allowlist. Empty (the default) means CORS is off. The single token * responds Access-Control-Allow-Origin: *. Otherwise a comma-separated allowlist of exact http(s) origins, e.g. http://localhost:5173,https://docs.example.com. Auth still applies either way — CORS only relaxes the browser's same-origin policy, never authorization. |
| LANYARD_TOKEN | — | Client-side: bearer token, falls back to the session saved by login. |
| LANYARD_SERVER | http://localhost:8080 | Client-side: server URL for CLI commands. |
| LANYARD_ADMIN_TOKEN | — | Read by lanyard init. The generated policy stores only the reference env://LANYARD_ADMIN_TOKEN, never the token itself. |
Config file
Anything above that is not a client-side or secret value can live in a YAML file instead, so a deployment does not need a wall of exports. Environment variables always win over the file — that ordering is what lets a container override one setting without rewriting the file.
addr: ":8080"
db_driver: postgres
db_dsn: "postgres://lanyard@localhost:5432/lanyard?sslmode=disable"
policy_file: policy.yaml
reconcile_interval: 1m
session_ttl: 12h
secret_manager: env
cors_origins: ["https://console.example.com"]
secret_manager fails at startup rather than falling back to a working one. Failing closed on a misconfigured secret backend is the same rule the grant path follows: on error, deny.Policy file
One YAML file declares who exists, what they can do, and how each entitlement is governed. Copy policy.example.yaml to start.
# providers that need it — the vendor account they map onto
identities:
- actor: alice@example.com
token_ref: env://LANYARD_TOKEN_ALICE
provider_logins:
github: alice-gh
roles: [requester, prod_approver]
# roles: a name mapped to (verb, entitlement) permissions — verb is
# one of request | approve | revoke
roles:
requester:
- verb: request
entitlement: "github:acme:repo/acme/api:push"
prod_approver:
- verb: approve
entitlement: "github:acme:repo/acme/api:push"
# management authority over non-entitlement resources — the
# least-privilege alternative to a full admin flag. role_view is the
# read-only half (the auditor case).
role_management:
platform_admin: [roles, bindings, integrations]
role_view:
auditor: [audit, roles, bindings]
# entitlements: per-entitlement caps, auto-approval, and the chain.
# Levels are AND; the roles inside one level are OR.
entitlements:
"github:acme:repo/acme/api:push":
policy:
max_duration: 24h
approval_chain: [[prod_approver], [platform_admin]]
"github:acme:repo/acme/docs:push":
policy:
max_duration: 1h
auto_approve: true
auto_approve_duration: 1h
auto_approve_requesters: [alice@example.com]
token_ref / *_ref is a reference, resolved at use — so lanyard stores the pointer, never the key. env://NAME is the only resolver implemented today; vault:// and aws-sm:// are planned.Approvals, chains and delegation
Anything policy does not auto-approve runs an ordered approval chain. Levels are AND — every one must be satisfied, in order. The roles inside a single level are OR — any one of them is enough.
"github:acme:repo/acme/api:push":
# team_lead first, then security OR compliance
approval_chain: [[team_lead], [security, compliance]]
| Rule | Behaviour |
|---|---|
| Levels | Up to 5, evaluated in order. Each needs one approval from an actor bound to one of its roles and able to approve that entitlement. |
| Quorum | Repeat a level. The same person can never satisfy two levels, so [[sre, security], [sre, security]] means two different people from those teams. |
| A level never widens | The chain says who approves and in what order; the role's own approve permission says what they may approve. Being named in a level grants no authority the role does not already carry (ADR 0012). |
| Vacant levels | A level no member of any of its roles can satisfy is skipped upward — but the final level always requires a human, and if every level is vacant only an admin can approve. |
| Self-approval | Rejected. So is approving your own delegated request. |
Delegation
A delegation is a time-bounded, pattern-scoped transfer of one actor's approval authority to another — for leave, or on-call handover. It confers nothing by itself: at approval time the delegator must currently hold the approving level's role, so a delegation can never hand over authority the delegator has since lost. It is non-transitive by construction — eligibility never chains through a second delegation — and, like a grant, it must be time-bounded.
-H "Authorization: Bearer $LANYARD_TOKEN" \
-d '{"delegate":"bob@example.com",
"scope":"github:acme:repo/acme/*:*",
"expires_at":"2026-08-05T09:00:00Z"}'
201 {"id":"d_41c9","delegator":"alice@example.com","revoked":false, …}
$ curl -X POST $LANYARD/delegations/d_41c9/revoke # ends it early
The delegator is always the caller — you cannot delegate authority you do not hold, nor delegate on someone else's behalf. expires_at is required and must be in the future: there is no unbounded delegation. An approval made under one records both the delegate and the delegator in the audit trail, so accountability is not laundered through it.
Approver notifications
Set LANYARD_NOTIFY_WEBHOOK to a secret reference and lanyard POSTs a summary of every pending grant to that endpoint. The body is the de-facto incoming-webhook shape ({"text": …}), so it renders in Slack, Discord and Mattermost with no extra configuration, and is readable by any custom consumer. Unset means notifications are simply off.
Providers & integrations
Vendor access is granted through pluggable provider adapters behind a router. GitHub App is the only provider that ships today — it covers repository collaborator and organisation team access. Everything else on the landing page's logo strip is planned, not shipped.
| Field | Type | Notes |
|---|---|---|
| app_id | int | GitHub App ID, must be positive. |
| installation_id | int | Installation ID for the target org, must be positive. |
| private_key_ref | string | Secret reference to the App's RSA private key, e.g. env://GITHUB_APP_KEY. |
{ "app_id": 123456, "installation_id": 789012, "private_key_ref": "env://GITHUB_APP_KEY" }
$ lanyard integration add github --name acme --config-file github-app.json
✓ registered · provider=github · name=acme
One provider can have several integrations, which is why --name is required — a broker fronting two GitHub orgs registers each separately, and the entitlement's instance segment is what picks between them at grant time (ADR 0007). Registering an integration is itself a gated, audited action: it needs management authority over integrations, because an integration points lanyard's credentials at a target.
New providers implement one port — Grant, Revoke, HasAccess, List — and are validated against a shared conformance kit (internal/testutil) before they ship. The kit fatals on an incomplete fixture set, so a check that was skipped can never read as a check that passed.
Storage
Set LANYARD_DB_DRIVER to choose a backend; all three implement the same repository interfaces.
memory
In-process only — fast, nothing persists across a restart. Good for tests and demos.
sqlite default
A single local file (LANYARD_DB_DSN, default lanyard.db). No external dependency to run.
postgres
Set LANYARD_DB_DSN to a connection string. Recommended once more than one server instance is running.
Migrations and upgrades
Schema and binary move independently. lanyard db:migrate applies pending schema migrations; lanyard update upgrades the binary. Run the migration after an upgrade that ships one (ADR 0016). A fresh database is baselined at its actual version rather than assumed to be at head, so adopting an existing deployment does not skip migrations it still needs.
✓ lanyard v0.4.1 → v0.5.0
$ lanyard db:migrate
✓ applied 2 migrations · schema at 0007
Reconciliation & audit
The two things to watch on a running lanyard: whether enforcement is still happening, and whether the record of what happened is intact. They are deliberately separate — one governs the future, the other the past.
Is enforcement still running?
Every sweep records its outcome durably, so GET /reconciliation can answer whether lanyard is still enforcing expiry — not just whether the last sweep found nothing. This matters because the failure modes look identical from outside: a sweep that ran and found no problems and a sweep that never ran both report zero problems. The status carries when the last sweep completed, what it repaired, and what it could not (ADR 0014). The console surfaces it at /console/reconciliation, and reconciliation is its own role_view resource so someone on call can read it without being able to read the audit trail.
200 {
"last_run": { "started_at": "…", "completed_at": "…",
"scanned": 42, "expired": 3, "repaired": 1, "failed": 0,
"record_failures": 0, "fully_recorded": true },
"interval_seconds": 60,
"open": [], "recently_closed": []
}
fully_recorded is served rather than left for you to derive from record_failures. "A sweep that lost observations is never shown as healthy" is a safety property, and deriving it separately in the API and the front-end is exactly how the two come to disagree.
Is the record intact?
Every event is linked into a tamper-evident hash chain, each entry covering the one before it. GET /audit/verify walks the chain and reports the first break, so a deleted or rewritten row is detectable rather than merely unlikely (ADR 0006). Each event answers who, what, when, where, why and how, and for configuration changes it carries the before/after values — a widened role shows exactly what widened (ADR 0015).
# 1 on tampering or any error, so it can gate a cron job or CI
$ lanyard audit verify
OK: audit trail intact (1284 event(s) verified)
$ curl $LANYARD/audit/verify -H "Authorization: Bearer $LANYARD_TOKEN"
200 {"intact":true,"verified":1284}
audit and reconciliation are separate role_view resources, so an auditor and an on-call engineer get exactly the one they need.