This blog has spent months repeating the same thing: your password should never
leave your browser. The
generator draws its randomness from crypto.getRandomValues() on your
own machine, and the checker analyses what you type without
sending it anywhere.
And today we are publishing an API that generates passwords on a server.
The contradiction is obvious and we are not going to gloss over it: every
API response carries a notice saying exactly that. But there are places where
there is no browser — a script provisioning a hundred accounts, a server minting
a temporary credential, an assistant that someone asks for a password and that
makes one up — and there the alternative is not “the browser”: it is a badly
chosen random() or a string typed by hand. That is what this is for.
The whole call
No key, no sign-up, no headers. This is all of it:
curl -X POST https://api.password.es/v1/generate
It returns a 16-character password and its analysis. Run while this paragraph
was being written: x*7$9,BUy9PPvOI?, drawn from an 89-character alphabet,
103.6 bits of entropy, and a crack time of 2.5 × 10¹¹ years. Level 4 out of 4.
None of those numbers is new. They come from the same engine that draws the meter on the home page: the same bits formula, the same attack model — 10¹² guesses per second, offline, fast hash — and the same level scale as the checker. If the site and the API returned different numbers for the same password, one of the two would be lying.
What comes back, field by field
The response carries passwords — always an array, even when you ask for one —
and an analysis block with length, pool, bits, log10_guesses,
crack_time_log10_seconds, crack_time, level, level_scale and ceiling.
Two of those deserve a paragraph. level_scale says where the level came
from (today always "time"), so that a future divergence between the site and
the API shows up in the field instead of having to be inferred by comparing
numbers. And ceiling says whether the figure is exact or an upper bound:
in generate it is always false, because the server made the password and
knows which alphabet it used; in the web checker it cannot always be, because
there the password is one you brought.
Then comes notice, the warning, and a _meta block with the plan, the
language that was applied, the limits and a link back to the documentation.
The options
All optional, and named in English because whoever integrates an API types the
field names verbatim: length (4–64, default 16), count (1–20), lower,
upper, digits, symbols, exclude_ambiguous, no_repeats and lang.
curl -X POST https://api.password.es/v1/generate -H 'content-type: application/json' -d '{"length":20,"exclude_ambiguous":true}'
Two details the documentation spells out and that are worth knowing before you
integrate. exclude_ambiguous drops six characters, not seven: it removes
0 O 1 I l | o, but the vertical bar was never in the symbol set to begin with,
so the alphabet goes from 89 down to 83 and not to 82. That is why the response
reads pool 83. And no_repeats is not an absolute guarantee: it retries
ten times, exactly as the web generator does, and at 64 characters an adjacent
repeat slips through about 0.1% of the time. Saying so is more useful than
promising otherwise.
The language
By default it answers in English, which is what someone integrating without
saying anything expects. You change it with ?lang= in the URL, with "lang"
in the body or with the Accept-Language header, and if they disagree the first
one wins. An unknown language is not an error: it falls back to English.
There is an asymmetry here that you cannot guess, which is why the API declares
it: messages exist in English and Spanish; links, in all eighteen of the
site’s languages. Asking for German gives you German links and messages still
in English. You do not have to assume: _meta.lang carries
{ "messages", "links" } with what was applied to each half.
The MCP server
MCP is the protocol an assistant — Claude, ChatGPT — uses to reach external
tools. https://api.password.es/mcp is an MCP server with no sign-up and no
key, and it publishes a single tool: generate_password, with the same
parameters as above.
This is the part we care about most, and not for technical reasons: an
assistant with nowhere to get a password from will make one up, and what comes
out of that is not random, it is whatever the model considers password-shaped.
With the server connected it stops improvising and returns one made with
crypto.getRandomValues(), with its analysis and with the warning included in
the tool description and in every result. That warning is exactly what the
assistant ends up reading back to whoever asked for the password, which is why
it is there.
If you are coming from other MCP servers, one fact: this one is stateless.
The POST is answered with JSON and opens no stream, Mcp-Session-Id is neither
issued nor expected, a notification is answered with 202 and no body, and GET
returns 405. The 2025-06-18 specification allows all of that explicitly, but
anyone expecting sessions and SSE will debug blind if nobody tells them.
What it does not do
/v1/check returns 501. That is not an oversight or a half-finished
endpoint: it is deliberate, and its own response explains why. Returning the
same numbers as the checker requires the very same pattern engine that runs on
the site, and that costs between 11 ms and 3.6 s of CPU per request depending on
what you send it. On a service with no sign-up and no key, that spread is a
product decision — where to put the length cap — that has not been made yet.
Meanwhile the 501 carries a checker_url pointing at the web checker in the
language you asked for, which does exactly this and sends nothing.
There are no accounts either, no keys and no plans. And because they do not exist, there is not a single link to a sign-up anywhere in the API: not even in the rate-limit error, which is where everyone puts one.
The limits
One, and it is the one actually enforced: 60 requests per minute per IP. You
do not have to take this page’s word for it — the number travels inside every
response, in _meta.limits.burst. Go over it and you get a 429 with
Retry-After and RateLimit-* headers.
In _meta you will also see a quota block with its values at null. It is
the slot reserved for when accounts exist, and it goes empty on purpose: a
limit announced and not enforced is worse than announcing none, because
whoever integrates respects it and codes against a number nobody is counting.
And even so, think twice
An API that generates passwords is, deep down, an antipattern: the password travels over the network and passes through a machine that is not yours. Us storing nothing does not change the shape of the problem, only our part of it — and you already know what a promise you cannot verify is worth.
That is why the warning does not live on this page alone: it travels in every response, in the language you ask for. And that is why this paragraph is here and not tucked away at the end of the documentation. For a password you are going to use yourself, this site’s generator runs entirely in your browser and sends nothing. The API is for the other thing: what happens with nobody in front of a screen.
The details live on two pages. What the API is covers who it is for
and what answers today and what does not. The reference has the
nine parameters, every field explained one by one, the error codes and the
limits; it is the one you open with your editor beside it. And if what you want
is for a machine to read it, there is openapi.json.
Sources: the API itself, verified against production on 31 August 2026 —
POST /v1/generate, POST /v1/check (501), POST /mcp and GET /openapi.json
· the analysis figures come from the same engine as the password.es generator
and checker · the attack model is 10¹² guesses per second, offline, fast hash,
the same one as the rest of the site · the MCP server implements the 2025-06-18
specification with stateless Streamable HTTP transport.
Photo by Stanislav Kondratiev · Pexels