Secure by default¶
A provider that can be configured insecurely will be, and the person who pays for it is never the one who
wrote the option. So Beffroi has no option to do it wrong: the protections below are properties of the
code. There is no insecure_ setting, no compatibility mode, and no global switch.
What is always on¶
| Rule | Why |
|---|---|
PKCE is mandatory, S256 only |
plain protects nothing against an attacker who reads the request. A code intercepted without the verifier is useless |
| No implicit flow | Tokens in a URL fragment end up in history, logs and referrers |
| No resource owner password credentials grant | It teaches applications to collect passwords, which is the habit the protocol exists to break |
| Redirect URIs are compared exactly, the port of a public client's loopback URI aside | Prefix and wildcard matching is how an open redirector becomes a token leak |
iss is in every authorization response |
A client must be able to tell which provider answered. RFC 9207 |
Access tokens are at+jwt or opaque references, never an ambiguous shape |
A resource server must know what it is validating. RFC 9068 |
| No redirect is followed outbound | Fetching a client's metadata or keys must not become a request to an address the client chose |
| No boolean from a third party is ever cast | A missing claim and a false claim are not the same thing, and (bool) makes them one |
| Every secret string comes from a cryptographic source, every secret comparison is constant time | |
| A password is compared against a list of compromised values before it is written | A password can be long, varied and still known to everybody. NIST SP 800-63B §3.1.1.2 asks for the list, and what the list holds is refused rather than warned about |
| A password is fifteen characters at least where it signs somebody in alone, eight where a second factor goes with it | NIST SP 800-63B §3.1.1.2 makes the length two numbers, and the floor of the logins of the tenant says which one a password serves |
| A page that adds or takes away a way in asks for the proof again | A session outlives the moment it was opened, which is right for a page that shows what the account holds and wrong for the gesture somebody else would lock its owner out with |
| What leaves the process on a transport leaves sealed | What a column seals, a queue may not carry in clear: the body of every message is sealed to the tenant, so a broker, a failure table and a backup of either are worth nothing without the key management service |
| Every end of a session revokes what was issued under it | However a session ends, at the end session endpoint, from the account, by the console or by a SAML logout, its refresh tokens are revoked, its opaque access tokens removed, and the provider refuses every token of it from then on. What a resource server verifies offline, a self-contained access token, lasts until it expires, which is why it lives ten minutes by default |
| A form another site posts a browser to the provider with is posted again from the provider's own origin, and a post from another site is never answered with a new session cookie | Under SameSite=Lax a cross-site POST arrives without the session. Answered with a new cookie, it would sign the browser out of the provider; posted again, it reaches the session the browser holds. Both rules read where the post comes from in Sec-Fetch-Site, and a browser that does not send it is answered as it always was |
| A person of a domain an upstream identity provider answers for signs in there and nowhere else | A local password would outlive the person's departure from the directory of their organisation, so none is posed for them and none is accepted from them |
| In SAML, every assertion the provider writes is signed, no signature under SHA-1 is accepted, nothing is encrypted in CBC, and no response is taken that nobody asked for | SAML 2.0 gives the whole list, and why each rule is there |
Where an exception exists¶
An exception is named, bounded and written down where it applies, for one client or for one tenant, and never a global switch. The shape they take:
- PKCE can be waived for a confidential client, never for a public one. A client declared in the
configuration that cannot send a
code_challengeis declared withrequires_pkce: false, and the clients a tenant lets register themselves are exempted together only when its registration policy saysexempt_confidential_clients_from_pkce: true. A public client is refused either way,plaindoes not exist, and a challenge a client does send is verified like any other. - A public client's loopback URI matches on any port. A native application listens on whatever port the
system gives it (RFC 8252 §7.3), so for a public client, and for a URI on
127.0.0.1or[::1]only, the comparison ignores the port and nothing else: another path or a host name instead of the IP literal is another URI. - Dynamic client registration is a decision of the tenant. A tenant that names no
client_registration.modehas no registration endpoint at all, in its discovery document as on the wire. One that names a mode says who may register: whoever holds an initial access token it issued, whoever presents a statement of an authority it pinned, or anybody. - An unsigned SAML request is accepted from one service provider at a time. A service provider that
cannot sign its
AuthnRequest, as Microsoft Entra ID federating its guests cannot, is declared so, and its unsigned requests are then accepted when its own metadata do not say it signs. Nothing turns the signature off for a tenant or a deployment, and a logout request is signed whatever the declaration says.
The floor of the logins of a tenant is a predicate, and a predicate only ever asks for more proofs: a tenant can require two factors or a passkey of every login, and no floor can forbid the stronger credential or let a login prove less than one factor. A floor that leaves out a kind of credential forgets every credential of that kind rather than leaving it dormant. The floor of its logins says what it changes.
Keys and personal data¶
Signing keys and the data keys that protect personal data are held by a key management service, never by the application: the application asks for an operation and never holds the master key. Keys rotate without a restart and without breaking a token already in flight.
Personal data is sealed in its column, with blind indexes for the lookups that must still work. A dump of the database is searched byte by byte by a test that fails when it finds a value it should not, and the same search finds a value the schema declares readable, so a green run is a search that was looking.
A key works for an age and no longer: a successor is staged behind it without anybody asking, and
beffroi:keys:list --check fails on a key that has worked past that age with nothing staged behind it, so
the question is answered in a deployment pipeline rather than at the next rotation.
The one file in which everything the provider holds of a person stands in clear at once is the archive that person asks for, and it is held by three rules rather than by a setting: it is sealed where it sits like every other column, it expires a week after it was asked for, and that week is a figure in the code. A person who wants it to leave under a passphrase of their own choosing says so when they download it, and the floor on the length of that passphrase is refused rather than warned about, since what stands between that file and whoever took it is the length of what was typed.
Keys and personal data.
What a deployment answers with¶
Two layers write the headers, and where they meet, one gives way. The bundle gives every page of the
interaction plane X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store and the
floor of a content policy; the application writes the headers of a deployment on every answer. Where both
name a header, the value is the same or the bundle's floor is dropped before the deployment's policy is
written, so one policy is sent: a header written twice is not replaced, it collides, two content policies
intersect, two Access-Control-Allow-Origin are invalid and the response is rejected.
What the standalone application answers with is therefore named where that
application is configured and nowhere else:
nosniff and DENY everywhere, Referrer-Policy: no-referrer, HSTS for a year with the subdomains and
the preload list, a permissions policy whose whole set is written down with two entries open to this
origin alone, the two a passkey ceremony needs, and a content policy that starts at default-src 'none'
and then names one directive per kind of thing a page of this deployment loads. Neither unsafe-inline
nor unsafe-eval is one of them.
The one header of that configuration that opens anything is CORS, so it is cut to what needs it: the six endpoints a public client running in a page calls, which are discovery, the JWK Set, the token endpoint, UserInfo, revocation and the pushed authorization request. Credentials are never allowed, which is what makes the wildcard correct: a browser refuses the two together, and the session of this provider is read on pages of the interaction plane, none of which is on that list.