Origin
Sake.Plug's origins: option checks the Origin header of every
request that sends one. Only a browser sends an Origin header at all —
Claude Code, claude.ai's connectors, a CLI, a server-to-server
integration, none of them do — so a request with no Origin header is
always allowed, regardless of origins:; this check only ever matters for
a browser-based client, and only once one shows up.
A request that does carry an Origin header must be allowed on
purpose: the default (origins: omitted, i.e. nil) refuses it, full
stop. There is deliberately no implicit "the endpoint's own origin"
default computed from the request's own Host header — an earlier
version of this check did exactly that, and since Host is exactly what
an attacker performing DNS rebinding controls, an attacker whose Origin
header simply matched whatever Host they sent defeated it trivially, on
every endpoint. origins: ["https://app.example.com", ...] allows a
request whose (lower-cased) Origin is in the list; origins: :any
allows any Origin at all — reach for a list rather than :any unless
the endpoint is genuinely meant to be called from arbitrary browser
pages. A browser client of your own MCP server needs its origin added to
the list; nothing else does.
HTTPS
The authorization server's issuer must be an https:// URL —
Sake.Auth.Config.new/1 raises otherwise — because bearer tokens and
authorization codes are only as safe as the transport they travel over.
allow_insecure: true (below) is the explicit, development-only escape
hatch.
PKCE
Every authorization code exchange requires a code_challenge (S256 only)
at the authorize step and the matching code_verifier at the token step;
Sake's PKCE check compares them with Plug.Crypto.secure_compare/2
(constant-time) and rejects a verifier outside the 43–128 byte range RFC
7636 requires. There is no plain method — S256 only.
Hashed tokens, never stored raw
A SHA-256, hex-encoded hash is what actually lands in
a store row's hash column; the plain access and refresh tokens returned
to a client are never persisted anywhere. A compromised database dump
cannot be replayed as a bearer token or a refresh token — only a live
in-flight token (already in a client's or an attacker's hands some other
way) works at all.
Family revocation
An authorization code and every access/refresh pair descended from it
share a family_id. Replaying a spent code, or a refresh token that was
already used once, revokes the whole family rather than denying just
that one request — a stolen, already-superseded credential cannot be used
to mint a fresh pair once the legitimate holder has moved past it. See
guides/authorization.md's "Token lifetimes and rotation".
The CIMD fetch guard, and what it does not cover
Sake.Auth.CIMD.fetch/2 resolves the client-supplied HTTPS host's
addresses and refuses to proceed unless every one of them is a public
unicast address (no loopback, link-local, RFC 1918, carrier-grade NAT,
multicast, reserved or benchmarking range, and no IPv6 address that
embeds or translates to an IPv4 one: IPv4-mapped, IPv4-compatible,
NAT64 64:ff9b::/96, 6to4 2002::/16) — this stops a client from
pointing your server's outbound fetch at its own internal network. A
client_id URL with credentials in it is refused, the whole download
runs under one 5-second deadline (a server that trickles bytes cannot
hold a request open), a document may list at most 10 redirect URIs, and
the document cache holds at most 1000 entries. It does not pin the actual HTTP connection to the address it
just checked: the check-then-connect happens as two separate steps
(:inet.getaddrs/2, then Req's own connection), and a hostile or
compromised DNS resolver that returns a public address to the check and a
private one to the actual connect (a classic TOCTOU/DNS-rebinding gap)
can still redirect the fetch. Mitigating this fully needs connecting to
the checked address directly rather than resolving the hostname a second
time — not implemented in 0.1. This is a real but narrow risk: it needs
control over DNS resolution for the specific client-supplied host, not
just the ability to register a client.
requestState
The opaque requestState a multi-round-trip tool call carries between an
input-required response and its retry (the requestState field) is a
signed token binding the principal, the method, the component name, and a
digest of the original arguments — a retry cannot be replayed against a
different principal, a different tool, or with different arguments than
the ones that triggered the original elicitation, and expires after ten
minutes. inputResponses sent without a requestState are rejected
outright, even if they would otherwise look like a plausible answer: there
is no unsigned path back into a handler that is already mid-flow.
Loopback redirects
Sake accepts https:// redirect URIs
unconditionally, and http:// ones only on 127.0.0.1, [::1],
localhost or ::1 — RFC 8252's rule for native/CLI clients that cannot
reasonably terminate TLS on a local port. A loopback redirect's port is
allowed to differ from what was registered (the OS assigns an ephemeral
port to the client's local listener each run); everything else about the
URI must match exactly.
The consent form
The consent form is protected in two layers:
- The signed request blob. The form's hidden
requestfield is signed with yoursecret_key_base, bound to the signed-in person's subject ID, and expires after ten minutes. A form posted for anyone else, or later, is refused. - CSRF protection, when your pipeline has it. When the authorize
routes run behind
Plug.CSRFProtection(protect_from_forgeryin a Phoenix:browserpipeline), the consent form carries the real CSRF token and your pipeline checks it. A Plug-only host without CSRF middleware gets an empty_csrf_tokenfield and relies on the signed blob alone — which is bound to the signed-in subject and expires in ten minutes.
The consent and error pages are sent with x-frame-options: DENY and
content-security-policy: frame-ancestors 'none', so no other site can
frame them and trick a person into clicking "Allow" (clickjacking).
Rate limiting
Sake does not rate-limit anything itself — that is your reverse proxy's or
your Plug pipeline's job, and it belongs in front of the specific
endpoints that need it: POST /oauth/register (unauthenticated by
design, see guides/authorization.md), the token endpoint (bearer-adjacent
brute-force surface), and the authorize/consent endpoints (email- or
account-enumeration-adjacent, once behind your own login).
allow_insecure
auth: [..., allow_insecure: true]Lets the issuer be an http:// URL (for mix dev and tests) and widens
what Sake.Auth.CIMD will fetch from to include plain http:// client-id
URLs. Never set this outside development — every guarantee above about
tokens travelling only over TLS depends on it being false.