Identity your application owns. Not a server you have to trust.

SESAME is a headless authentication and authorization engine. It opens no port and ships no UI — your application owns every listener and drives the engine over a versioned protocol on stdin and stdout. Sessions, tokens, policy, and audit stay inside your own stack, in any of 10 languages.

go install github.com/d31ma/sesame/cmd/sesame@latest copy
✓ No network listener ✓ One direct dependency ✓ Default deny, always ✓ Apache-2.0
84
protocol operations
10
language SDKs
1
direct dependency
0
ports opened
What it does

One place for who, what, and why.

Principals, credentials, sessions, tokens, policy, and audit — with the decision boundary in the same engine as the identities it decides about.

Authenticate

Passkeys first, passwords properly

WebAuthn passkeys with sign-counter clone detection, Argon2id passwords with a parameter-upgrade path, TOTP, and single-use recovery codes. A code is spent durably, so a replay fails inside its own window and across a restart.

Authorize

Decisions you can explain

Default deny over roles, grants, groups, and bounded context conditions. Every decision returns a stable reason and the policy version it was made under, so an audit answers "why" and not just "no".

Single sign-on

OIDC provider, with PKCE mandatory

Authorization code flow, rotating refresh families that revoke the whole family on reuse, discovery, introspection, revocation, consent, and RP-initiated logout. There is no grant-types field, so implicit and password can never be switched on.

Federate

Inbound OIDC and SAML 2.0

Bring identities from an existing provider. SAML verification refuses every ambiguous document rather than choosing between readings, which is what defeats signature wrapping.

Provision

SCIM 2.0 users and groups

A directory can create, patch, and deprovision principals and group membership. Reconciliation is idempotent, because directories re-send full state rather than diffs.

Account for

A ledger, not a log file

Every security-relevant change is a hash-chained event on FYLO. Projections are rebuildable, snapshots are verified, and revocation survives a restart with a defined propagation bound.

How it fits

A subprocess, not a service.

Most identity servers ask you to run one more thing on the network and trust it with your users. SESAME is a binary your application starts. It has no listener to expose, no admin console to leak, and no origin to be confused about.

Your application Owns every listener, TLS boundary, route, and middleware chain. Nothing about that changes.
SDK shim One dependency-free file. Owns the subprocess, timeouts, cancellation, and typed errors — never a security decision.
SESAME engine NDJSON on stdin and stdout. Every protocol decision, every policy evaluation, every audit record.
FYLO Documents are authoritative. Indexes, caches, and projections are derived and rebuildable.

The architecture is enforced, not documented: a test inspects the linked dependency graph of the shipped binary and fails the build if an HTTP package, a template engine, or an unexpected module ever appears in it.

In your language

Ten languages, one engine.

Copy one file into your project. It spawns the engine, speaks the protocol, and gets out of the way. There is no registry to depend on and no lockfile entry to resolve — and with SESAME_DEPLOYMENT set, no path in your code either.

// SESAME_BINARY and SESAME_DEPLOYMENT come from the environment.
client, err := sesame.Start(ctx, sesame.Options{})
if err != nil {
    log.Fatal(err)
}
defer client.Close()

decision, err := client.Decide(ctx, sesame.DecisionRequest{
    TenantID:    tenantID,
    PrincipalID: principalID,
    Action:      "doc:read",
    Resource:    "project:alpha",
}, nil)

fmt.Println(decision.Decision, decision.ReasonCode)
// deny deny_no_grant
Features

Everything it does. And everything it does not.

A security project that overstates itself is worse than one that ships less. On the left is what exists — each of them with code, negative-path tests, operator documentation, and recovery evidence behind it. On the right is what does not exist yet, written down here rather than left for you to find out in production.

OAuth 2.0 / OIDC provider

Authorization code flow with mandatory PKCE, rotating refresh families with reuse detection, discovery, introspection, revocation, consent, and RP-initiated logout.

Device grant, PAR, and DPoP

RFC 8628 for inputs a browser cannot reach, RFC 9126 to move the authorization request onto an authenticated back channel, and RFC 9449 to bind an access token to a key its holder proves per request.

Authentication

Argon2id passwords with a parameter-upgrade path, TOTP with durable replay prevention, single-use recovery codes, and WebAuthn passkeys with clone detection.

Authorization

Deterministic default-deny decisions over roles, grants, groups, and bounded context conditions, with stable reasons and a versioned policy snapshot.

Federation and provisioning

Inbound OIDC federation, SCIM 2.0 users and groups, and inbound SAML 2.0 with an in-tree XML canonicalizer that refuses every ambiguous document.

Durability

A hash-chained security ledger on FYLO with rebuildable projections, verified snapshots, and restart evidence run against a real FYLO runtime.

Ten drop-in clients

One dependency-free file per language, each holding the whole operation surface. A contract test resolves every operation against every shim, so a capability cannot ship in one language and quietly miss another.

OpenID certification

The provider surface is driven over real TLS by a relying party in test/interop — discovery, PKCE, ID-token claims, single-use codes. Certification is self-certification against the OpenID Foundation suite, and that submission has not been made, so SESAME is not certified.

SAML interoperability

Proven end to end against a real Keycloak in test/interop — which is how a namespace defect no fixture had ever produced was found and fixed. Okta, Entra ID, Google Workspace, and Shibboleth are still unproven; treat any provider not listed as untested.

Production support

This is a developer preview. A repeatable restore, upgrade/rollback, and soak evidence runner exists, but no release has passed its 72-hour native gate or an independent security review. No version is supported for production use.

Every attack case in SESAME's adversarial suite runs against a real compiled binary over the shipped protocol, against a real deployment with real keys — because a defence that only exists inside a package boundary is not a defence.

Read the code before you trust it.

SESAME is Apache-2.0 and every security claim on this page points at a test you can run. Start with the project plan — it records what is proven, what is deliberately not, and why.

← Docs Guide

Device grant, PAR, and DPoP

Three OAuth extensions for the cases the plain code flow does not cover: a device with no browser, an authorization request you do not want the browser to be able to edit, and an access token you do not want to be a bearer token.

Which one you need

These are independent. You can use any, all, or none of them, and none of them changes how the ordinary authorization code flow behaves for a client that does not opt in.

  • Device grant (RFC 8628) — the input has no browser. A television, a CLI on a headless box, a thing with four buttons.
  • PAR (RFC 9126) — you want the authorization request fixed before a user agent ever sees it, so nothing between your client and the person can rewrite it.
  • DPoP (RFC 9449) — you want a captured access token to be worthless to whoever captured it.

Device grant

The device shows a short code, the person types it on a phone, and the device polls until they approve. Four operations, and the shape of each is decided by one fact: the user code is typed by a human, so it is short — which makes it the only guessable credential in SESAME.

# 1. The device asks. It has no browser, so it gets two codes:
# one it keeps, one a person reads off the screen.
started = client.device_authorize(client_id, ["profile"])
# {"device_code": "...", "user_code": "HKMP-TVWX", "interval": 5,
#   "expires_in": 600, "verification_uri": None}

# 2. On a phone, your own page shows what is being asked for.
pending = client.device_lookup(tenant_id, typed_code)
# {"client_name": "living-room-tv", "scopes": ["openid", "profile"]}

# 3. Approval proves a SESSION, not a principal. A caller that could
# merely name a principal could attach any device to anyone.
client.device_approve(tenant_id, typed_code,
                      session["session_id"], session["session_secret"])

# 4. Meanwhile the device polls. Only authorization_pending invites
# another poll; slow_down means add five seconds; access_denied
# covers refused, expired, and never-existed alike.
tokens = client.token_exchange({
    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
    "device_code": started["device_code"],
    "client_id": client_id,
})

That is why the user code is drawn from a twenty-symbol alphabet with no O, 0, I, l, 1, S, 5, or U; why wrong guesses are bounded at five before the whole authorization is denied; and why the flow lives ten minutes rather than the tens of minutes RFC 8628 permits.

The three polling outcomes

The token endpoint's answers are deliberately asymmetric, and they keep RFC 8628's own spelling because off-the-shelf device libraries branch on those exact strings.

  • authorization_pending — the only outcome that invites another poll.
  • slow_down — you are polling faster than the interval you were issued. Add five seconds and keep going; this is guidance, not a refusal, because a device polling too fast is usually badly written rather than hostile.
  • access_denied — refused, expired, or never existed. One code for all three, so a device cannot probe your verification page through the token endpoint.

Approval takes a session, not a principal ID. This is the moment a person's identity attaches to a device they are holding, and a caller that could merely assert a principal could attach any device to anyone. Revoking that session stops the device collecting tokens afterwards, even if it was already approved.

Pushed authorization requests

In a plain code flow every parameter — the scopes, the redirect URI, the PKCE challenge — travels through the user agent. That means it lands in history, in referrer headers, in proxy logs, and within reach of anything that can rewrite a URL. PAR moves the whole request onto a back channel your client authenticates on, and hands back one opaque reference to put in the redirect.

# Push the whole request on the back channel, authenticated as the
# client. Nothing about it travels through the browser.
pushed = client.pushed_authorize({
    "client_id": client_id,
    "client_secret": client_secret,
    "redirect_uri": "https://app.example/cb",
    "response_type": "code",
    "scopes": ["profile"],
    "state": state, "nonce": nonce,
    "code_challenge": challenge, "code_challenge_method": "S256",
})
# {"request_uri": "urn:ietf:params:oauth:request_uri:par_...",
#   "expires_in": 90}

# The redirect now carries one opaque string with nothing to edit.
redirect_to = f"/authorize?client_id={client_id}&request_uri={pushed['request_uri']}"

# Redeeming it takes the reference and NOTHING else. Sending a
# scope or a redirect_uri beside it is refused with
# request_uri_conflict — merging them would restore exactly the
# tampering the push removed.
started = client.authorize({"client_id": client_id,
                            "request_uri": pushed["request_uri"]})

Why a reference beside parameters is refused

RFC 9126 forbids merging a request_uri with loose parameters. SESAME goes further and refuses the request outright with request_uri_conflict — including when the loose value is byte-identical to the pushed one.

Ignoring them would be quieter and worse. A client could not tell whether the values it sent had been used, and something that appended a parameter on the way through would look like it had succeeded. A refusal is the only answer that tells you the truth.

The reference is not a secret

It reaches the browser, so it reaches everything the browser touches. What makes that harmless is not secrecy — it is that the reference is single-use, bound to the client that pushed it, and lives ninety seconds, which is the gap between building a redirect and issuing it.

Unknown, expired, already-spent, and belonging-to-another-client all come back as one request_uri_not_found. A browser-facing endpoint that told them apart would report on requests other clients had pushed.

The reference is spent when the authorization starts, not when the flow succeeds. Spending it only on success would leave a window in which a reference somebody observed in a redirect is still live — and that window is exactly what an attacker wants.

DPoP

A bearer token is whoever holds it. Every layer between your client and your resource — a proxy, an access log, a browser extension, a leaked backup — is somewhere it can be picked up and replayed, and nothing about the token objects.

DPoP fixes that by having the client keep a key pair and sign a fresh proof for every request. The token carries the key's thumbprint in cnf.jkt, so a token captured without the private key is worth nothing.

# Your client mints a proof per request with its own P-256 key.
# SESAME never sees the private half.
proof = sign_dpop_proof(key, jti=uuid4().hex, htm="POST",
                        htu="https://id.example/oauth/token")

tokens = client.token_exchange({
    "grant_type": "authorization_code",
    "code": code, "redirect_uri": redirect_uri,
    "client_id": client_id, "client_secret": client_secret,
    "code_verifier": verifier,
    "dpop_proof": proof,
    # The engine speaks no HTTP. These are YOUR assertion about the
    # request you served — report them faithfully or the binding
    # decides nothing.
    "http_method": "POST", "http_uri": "https://id.example/oauth/token",
})
# {"token_type": "DPoP", ...}  — not "Bearer", so a resource server
# expecting a bearer token fails loudly instead of accepting it.

# At the resource, check the token against a fresh proof.
verified = client.dpop_verify(access_token, request_proof,
                              "GET", "https://id.example/api/invoices")
# {"active": true, "principal_id": "prn_...", "dpop_thumbprint": "..."}

What the engine cannot see

This is the part to read twice. SESAME opens no listener and speaks no HTTP. A proof claims an HTTP method and URI, and the engine checks them against the http_method and http_uri you report — not against a request it observed.

A host that reports the wrong URI defeats the binding exactly as one that skipped the check would. Pass what the request actually was, from your framework's own request object, not a constant you typed once.

What the engine guarantees on its own is narrower and still worth having: whatever URI you report must belong to this deployment's issuer origin, or the proof is refused with dpop_foreign_origin. A proof minted for somebody else's authorization server is refused even by a host that reported it faithfully.

Three bindings, all enforced

  • To one request. htm and htu pin the proof to a method and a URI, so it cannot be lifted from a cheap endpoint onto an expensive one. Query strings and fragments are excluded; a trailing slash, a traversal segment, and a percent-escape are not normalised away.
  • To one token. ath is the hash of the access token. A proof presented alongside a token but carrying no ath is refused rather than accepted as unbound.
  • To one use. The proof's jti is spent durably, so a replay is refused for the proof's whole one-minute window — and across a restart.

The token type changes

A key-bound token comes back as "token_type": "DPoP", not "Bearer", and must be presented as Authorization: DPoP <token>. The different scheme is the point: a DPoP token handed to a resource server expecting a bearer token fails loudly instead of being accepted without the proof that is the whole reason for the binding.

Refresh tokens stay bound

A refresh token issued to a key is refused without a proof (dpop_required) and refused under a different key (dpop_key_mismatch), and its successor carries the same thumbprint. Without that, a stolen refresh token could be exchanged for an unbound bearer token, and the binding would be something an attacker simply declines to carry forward.

DPoP protects a token separated from its key. It does nothing for a client whose private key is taken — that client is compromised, and the answer is revocation, which stays durable and outranks key possession. A revoked session reports active: false no matter how good the proof is.

What is not implemented

  • use_dpop_nonce. A server-supplied nonce would narrow the proof window below the client's clock plus skew. It needs a nonce issuance surface and a host-side retry contract, and neither exists yet.
  • Per-client policy. There is no switch that makes DPoP or PAR mandatory for a given client; both are per request.
  • pushed_authorization_request_endpoint in discovery. The endpoint is one of your routes, and the host owns every route.
  • JAR — the signed request object — is not supported.

What to read next