attesto is the OAuth 2.0 / OpenID Connect authorization server, and it requires an
https issuer — the discovery documents, DPoP htu, and RFC 9728 protected-
resource identifiers are all https by spec, and attesto enforces that at
config-build time (RFC 8414 §2: the issuer identifier MUST be an https URL). So
a plain http://localhost dev server cannot drive the OAuth / MCP flow.
There is deliberately no "disable https" switch in the library — that would defeat
the point of a certified security layer. Instead, attesto_phoenix makes the
right path frictionless: serve a locally-trusted certificate on
https://localhost, so everything lines up with no tunnel and no downgrade.
Two ways to get https locally
- A tunnel (ngrok / cloudflared) — points a public https host at your local http port. Set your issuer / URL config to the tunnel host. Use this when you need a publicly reachable URL (for example, an MCP client on another device, or a mobile browser).
- mkcert (this guide) — serves a locally-trusted certificate on
https://localhostdirectly, no tunnel, no downgrade. Best for everyday local dev where the client runs on the same machine.
mkcert creates a certificate authority
that it trusts in your OS/browser trust stores, then issues certificates from it.
Your machine trusts https://localhost with no -k and no self-signed warnings.
One command
mix attesto_phoenix.gen.dev_https
That task:
- checks
mkcertis installed (printing install guidance if not), - creates
priv/cert/, - runs
mkcert -install(idempotent — trusts the local CA on this machine), - writes
priv/cert/localhost.pem+priv/cert/localhost-key.pemforlocalhost 127.0.0.1 ::1, and - ensures
priv/cert/is git-ignored.
If mkcert isn't on your PATH, install it first:
brew install mkcert nss # macOS; nss adds Firefox trust
# Linux / Windows: https://github.com/FiloSottile/mkcert#installation
priv/cert/ is git-ignored — every developer generates their own. The
certificate is trusted only by your machine's CA, so there is nothing to share
or commit.
Wire it into the dev endpoint (one line)
In config/dev.exs, hand the endpoint's https: listener to
AttestoPhoenix.DevTLS.https_opts/1:
config :my_app, MyAppWeb.Endpoint,
https: AttestoPhoenix.DevTLS.https_opts(port: 4443)https_opts/1 returns the full https: keyword — port, cipher_suite: :strong,
the resolved certfile/keyfile, and a raised max_header_length (DPoP proofs
and long tokens can exceed Bandit's default per-header cap). It resolves the
conventional priv/cert/localhost.pem + priv/cert/localhost-key.pem against
your app, and it raises (pointing back at mix attesto_phoenix.gen.dev_https)
if the certificate is missing — it never silently falls back to http.
Keep the plain http listener too if you want non-MCP routes on http as well; the
https: and http: keys coexist on the endpoint.
Options
https_opts/1 accepts:
:port— the TLS port (default4443).:certfile/:keyfile— explicit paths, if you don't use the convention.:otp_app— resolve the default cert/key paths viaApplication.app_dir/2instead of relative to the current working directory (release-safe; the plain default is the idiomaticmix phx.server-from-app-root spelling).:max_header_length— override the Bandithttp_1_optionsmax header length.
Point the issuer at the https port
Set your AttestoPhoenix.Config issuer (and any RFC 8707 resource identifiers
derived from it) to the mkcert https origin, so discovery, DPoP htu, and the
resource identifiers all match what a client discovers:
config :my_app, AttestoPhoenix.Config,
issuer: "https://localhost:4443",
audience: "https://localhost:4443/mcp"Verify — no -k, because the certificate is trusted:
mix phx.server
curl https://localhost:4443/.well-known/oauth-authorization-server
Point your MCP client (mcp-remote, Claude Desktop, etc.) at the concrete MCP transport URL on the same https origin — the OAuth dance then runs entirely over trusted https with no tunnel.
Node clients (mcp-remote) need one extra step. mkcert -install trusts the
CA in the system/browser stores, but Node ships its own root store, so a
Node-based MCP client rejects the cert until you point Node at the mkcert CA:
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"
(curl succeeds without this because it uses the system store; Node does not.)
Notes and caveats
- Not a replacement for a tunnel when you need a public URL. mkcert only trusts your machine. If a client on another device or the public internet must reach your dev server, use ngrok / cloudflared and set the issuer to the tunnel host instead.
- Certificates expire (mkcert defaults to ~2 years). Regenerate by re-running
mix attesto_phoenix.gen.dev_https. - Never on a server or in CI.
mkcert -installtrusts a CA on the local machine; the certificates are dev-only. Production terminates TLS with a real CA certificate at the load balancer / ingress. - The https guarantee stays intact. Nothing here disables attesto's https-only requirement — you are serving real (locally-trusted) TLS, which is exactly what the issuer requires.