Every route the server mounts, taken from server.js and src/routes/*.js.
GET /api returns the same list at runtime, and test/api-index.test.js
checks that the index matches the routes in the source.
Base URL. http://localhost:3000 by default (PORT), or
https://localhost:3443 when ENABLE_HTTPS=true (HTTPS_PORT).
Response envelope. JSON routes answer with the data spread at the top level:
{ "success": true, "message": "optional text", "...": "route-specific fields" }Errors use { "success": false, "error": "<message>" }. Some errors add a
machine-readable code (CSRF_INVALID, TWIN_EXISTS, SWAP_INCOMPLETE).
Every JSON 500 carries a fixed message and a reference ID, never the
underlying error (no paths, stderr, command lines or stack traces, whatever
NODE_ENV is). The SCEP protocol endpoint /scep answers a server error with
an empty 500, as SCEP clients expect.
{ "success": false, "error": "Failed to archive certificate", "ref": "3f9c1a0e" }The server log has the full error on a line that starts with the same
[ref 3f9c1a0e], plus the method and path. Where the cause helps an admin
act (the email, ntfy and webhook tests and SMTP verify; root CA install,
uninstall and generate), the body also has a short reason: an error code
such as ECONNREFUSED, EAUTH, ETIMEDOUT or ENOENT, or else one line of
the error with absolute paths replaced by <path> (at most 120 characters).
Download routes (/api/download/*, /api/generate/pfx/*,
/api/settings/export, GET /scep) send a file instead.
Authentication. With ENABLE_AUTH=true, routes marked "auth" in the tables
need a logged-in session (cookie mkcertweb.sid). Without one they return
401 { success: false, error: "Authentication required", redirectTo: "/login" }.
Pages: GET / without a session redirects to /login. The other pages
(/root-ca.html, …, and /index.html) are static files served to anyone;
their data comes from these routes, and the page goes to /login on the
first 401.
What is protected without ENABLE_AUTH. Authentication is on only when
ENABLE_AUTH=true. ENABLE_OIDC alone turns nothing on: the OIDC routes are
mounted only when ENABLE_AUTH=true. With auth off, every "auth" route is open
to anyone who can reach the server, including generate, upload, archive,
delete, POST /api/generate-ca and POST /api/install-ca (which only add
trust; generate is how a new container gets its CA). Two things refuse instead:
- the whole settings API (
/api/settings*) returns 403 (see Settings); mkcert -uninstall(POST /api/uninstall-caanduninstall-cainPOST /api/execute) returns 403.
# log in and keep the session cookie
curl -c jar -H 'Content-Type: application/json' \
-d '{"username":"demo","password":"demo-pass-123"}' \
http://localhost:3000/api/auth/loginCSRF. Every request that isn't GET, HEAD or OPTIONS needs a CSRF
token bound to the session. Fetch one, then send it in the X-CSRF-Token
header (a _csrf field in the body also works):
TOKEN=$(curl -s -b jar -c jar http://localhost:3000/api/csrf-token | node -pe 'JSON.parse(require("fs").readFileSync(0)).csrfToken')
curl -b jar -H "X-CSRF-Token: $TOKEN" -H 'Content-Type: application/json' \
-d '{"command":"generate","input":"app.dev.local 127.0.0.1","format":"pem"}' \
http://localhost:3000/api/executeA missing or invalid token returns 403 { success: false, error: "Invalid CSRF token", code: "CSRF_INVALID" }.
The token is tied to the session, so keep the cookie jar between the two calls.
Exempt paths: POST /api/auth/login, POST /login, POST /api/auth/logout,
/auth/oidc, /auth/oidc/callback and POST /scep.
Rate-limit tiers. Each route uses one limiter (express-rate-limit, in-memory,
RateLimit-* headers). Over the limit the response is 429 with
{ error, retryAfter }.
| Tier | Default | Keyed by | Configure with |
|---|---|---|---|
cli |
10 per 15 min | IP + user | CLI_RATE_LIMIT_WINDOW, CLI_RATE_LIMIT_MAX |
api |
100 per 15 min | IP + user | API_RATE_LIMIT_WINDOW, API_RATE_LIMIT_MAX |
auth |
5 per 15 min | IP | AUTH_RATE_LIMIT_WINDOW, AUTH_RATE_LIMIT_MAX |
general |
200 per 15 min | IP | fixed |
Windows are in milliseconds. Static files under public/ are not rate-limited.
Folder identifiers. Per-certificate routes take a :folder that names where
the files live under certificates/:
:folder |
Directory |
|---|---|
YYYY-MM-DD |
certificates/YYYY-MM-DD/ (where generated certificates go) |
interface-ssl, legacy |
certificates/ itself |
root-ca |
notes only (PUT …/notes) |
Any other folder (uploaded, scep, …) is rejected with 400. Archived copies
live in <folder>/archive/.
| Method | Path | Auth | Tier | Notes |
|---|---|---|---|---|
| GET | /api/auth/status |
— | general | { authenticated, username, authEnabled } (no success field) |
| GET | /api/auth/methods |
— | general | { basic: bool, oidc: { enabled: bool } } |
| POST | /api/auth/login |
— | auth | Body { username, password }. 200 { success, message, redirectTo: "/" }; 400 if a field is missing; 401 on bad credentials. Only mounted when ENABLE_AUTH=true. |
| POST | /api/auth/logout |
— | general | Destroys the session. { success, message, redirectTo }; for OIDC users redirectTo is the provider's end-session URL when it advertises one. Only mounted when ENABLE_AUTH=true. |
| GET | /api/csrf-token |
— | general | { success, csrfToken } |
| POST | /login |
— | auth | HTML form login (username, password), redirects to / or /login?error=… |
| GET | /auth/oidc, /auth/oidc/callback |
— | general | OIDC sign-in and callback. Mounted only when ENABLE_AUTH=true, ENABLE_OIDC=true and the issuer, client ID and secret are set. |
| Method | Path | Auth | Tier | Request | Response |
|---|---|---|---|---|---|
| GET | /api/certificates |
auth | general | — | { certificates: [...], total } |
| POST | /api/execute |
auth | cli | { command, input?, format?, confirm? } |
see below |
| POST | /api/upload |
auth | api | multipart, field certificates (one or more files) |
see below |
| POST | /api/certificates/:folder/:certname/archive |
auth | cli | — | { message } |
| POST | /api/certificates/:folder/:certname/restore |
auth | cli | query swap=true|false |
{ message, swapped } |
| DELETE | /api/certificates/:folder/:certname |
auth | cli | query archived=true|false |
{ message, deletedFiles, scope } |
| PUT | /api/certificates/:folder/:certname/notes |
auth | general | { note } (string, ≤ 2000 chars; empty clears it) |
{ note } |
| POST | /api/generate/pfx/:folder/:certname |
auth | cli | { password? } |
<certname>.pfx file |
| GET | /api/download/cert/:folder/:filename |
auth | general | — | the file |
| GET | /api/download/key/:folder/:filename |
auth | general | — | the file |
| GET | /api/download/bundle/:folder/:certname |
auth | general | — | <certname>.zip with the cert (.pem or .crt) and key (-key.pem or .key) |
GET /api/certificates. Scans certificates/ recursively for .pem,
.crt, .key, .p12 and .pfx files and groups each certificate with its key.
Each entry has name, cert and key (file objects with filename, size,
modified, relativePath, …; no absolute paths), domains (DNS SANs and CN), expiry, fingerprint (SHA-256),
format (pem, crt or p12), folder, folderDate, isArchived,
isInterfaceSSL, canEdit, and note when one is saved. Files named
.swap-* are never listed.
POST /api/execute. command is one of:
command |
What it runs | Response fields |
|---|---|---|
generate |
mkcert in certificates/<today>/. input is the space-separated names (the first one names the files). format: pem (default, <name>.pem + <name>-key.pem), crt (.crt + .key), p12, or p12-client (-pkcs12 -client). |
output, format |
caroot |
mkcert -CAROOT |
output, command |
install-ca |
Returns a note without running anything when the CA files already exist; otherwise mkcert -install. |
output, command, warning? |
uninstall-ca |
mkcert -uninstall. Needs ENABLE_AUTH=true (403 otherwise, even with OIDC configured) and "confirm": true (400 otherwise). |
output, command |
Any other value, including the removed list, gets 400 Invalid command.
POST /api/upload. Accepts .pem, .crt, .key, .p12 and .pfx, up to
10 MB each, and saves them to certificates/uploaded/ (UPLOADED_CERTS_DIR).
Files are staged in a private directory under the OS temp dir first. Response:
{ success, message, completePairs, incompletePairs: [{ certName, missing, hasFile }], errors: [], files: [{ name, size, type }] }.
Bad file types, bad names and oversized files return 400. Uploaded certificates
are listed but the per-certificate routes above reject the uploaded folder.
POST …/generate/pfx. Exports <certname>.pem + <certname>-key.pem from
the active (not archived) folder. Without a password the PFX has an empty one. A failure returns 500 with the generic error failed to generate PFX; the details are logged on the server only.
Known issue: a non-empty password currently fails with 500.
An active <folder>/<name>.pem and an archived <folder>/archive/<name>.pem can
exist at the same time. No route overwrites one with the other or deletes both:
| Route | Behaviour |
|---|---|
POST …/archive |
409 TWIN_EXISTS if an archived copy of the name already exists. 404 if there are no active files. |
POST …/restore |
404 if there is no archived copy. 409 TWIN_EXISTS if an active copy exists, unless ?swap=true, which exchanges the two pairs (with rollback if a rename fails) and returns swapped: true. 409 SWAP_INCOMPLETE while a leftover from an interrupted swap is present (see below). A swap whose temporary name would be too long returns 400. |
DELETE … |
Deletes exactly one copy: ?archived=true the archived copy, ?archived=false the active copy, no parameter the active copy if it exists, else the archived one. scope in the response says which (active or archived). The note is removed only when neither copy remains. 404 if the chosen copy has no files. |
?swap and ?archived accept only true or false; any other value is a 400.
A name that decodes to a path (for example a double-encoded archive%252Fname)
is a 400.
A swap renames, per file: active → temporary, archived → active, temporary →
archived. The temporary files sit in the archive folder and are named
.swap-<16 hex digits>-<name>.pem and .swap-<16 hex digits>-<name>-key.pem.
If a rename fails the server undoes the earlier ones. If the undo also fails,
the restore returns 500 ("…check its archive folder for .swap-* files") and the
temporary files stay behind. Until they are gone, every restore of that name
returns 409 SWAP_INCOMPLETE. The listing hides them and the server log names
each one.
To recover by hand:
- Stop making changes to that certificate and look in
certificates/<folder>/archive/(orcertificates/archive/forinterface-ssl/legacy). - Each
.swap-…file holds what was the active copy before the swap. Check which copy is where withopenssl x509 -noout -fingerprint -sha256 -enddate -in <file>. - Move each temporary file to the slot that is empty: back to
certificates/<folder>/<name>.pem(and-key.pem) if the active slot is empty, or toarchive/<name>.pemif the archived slot is empty. Never overwrite an existing file; if both slots are full, keep the.swap-file under a new name outsidecertificates/until you know which copy you want. - Once no
.swap-…-<name>…file is left in the archive folder, restore works again.
| Method | Path | Auth | Tier | Notes |
|---|---|---|---|---|
| GET | /api/rootca/info |
auth | general | { caRoot, subject, issuer, expiry, daysUntilExpiry, fingerprint, path }; 404 when rootCA.pem is missing |
| GET | /api/download/rootca |
auth | general | mkcert-rootCA.pem |
| POST | /api/install-ca |
auth | cli | Runs mkcert -install (adds the CA to this server's trust store; needs root). { output } |
| POST | /api/generate-ca |
auth | cli | If the CA exists: action: "none". Otherwise runs mkcert -install, which creates the CA and then tries the trust store. Succeeds when the CA files exist afterwards: { caRoot, caExists, action: "generated", trustStoreInstalled, caCopiedToPublic, output }. Copies the CA certificate to certificates/mkcert-rootCA.pem. |
| POST | /api/uninstall-ca |
auth | cli | Runs mkcert -uninstall. 403 unless ENABLE_AUTH=true (OIDC alone is not enough); 400 unless the body is { "confirm": true }. |
generate-ca and install-ca change the trust store of the machine running the
server. Don't call them against your workstation casually.
| Method | Path | Auth | Tier | Notes |
|---|---|---|---|---|
| GET | /api/email/status |
auth | api | { enabled, configured, smtp: { host, port, secure, user }, from, to } (user is masked) |
| POST | /api/email/verify |
auth | cli | Checks the SMTP connection. 400 if email is disabled or incomplete. |
| POST | /api/email/test |
auth | cli | Verifies SMTP, then sends a test email. |
| GET | /api/ntfy/status |
auth | api | { enabled, configured, url, topic, priority, authMethod } |
| POST | /api/ntfy/test |
auth | cli | Sends a test notification. |
| GET | /api/webhook/status |
auth | api | { enabled, configured, url, hasCustomHeaders } |
| POST | /api/webhook/test |
auth | cli | Sends a test payload. |
| GET | /api/monitoring/status |
auth | api | { enabled, running, schedule, warningDays, criticalDays, includeUploaded, emailEnabled, ntfyEnabled, webhookEnabled } |
| GET | /api/monitoring/expiring |
auth | api | { total, critical, warning, certificates: [{ path, expiry, daysUntilExpiry, domains, priority }] } for certificates expiring within the warning window. Sends nothing. |
| POST | /api/monitoring/check |
auth | cli | Runs an expiry check now (and sends alerts). 400 if monitoring is disabled. |
| POST | /api/monitoring/start |
auth | cli | Starts the scheduler. 400 if monitoring is disabled. |
| POST | /api/monitoring/stop |
auth | cli | Stops the scheduler. |
| PUT | /api/monitoring/config |
auth | cli | Body: any of warningDays, criticalDays (numbers 1–365), checkInterval (valid cron), includeUploaded (boolean). Applies to the running process only; not saved. |
| Method | Path | Auth | Tier | Notes |
|---|---|---|---|---|
| GET | /scep?operation=GetCACaps |
— | api | text/plain: Renewal, SHA-256, AES |
| GET | /scep?operation=GetCACert |
— | api | The mkcert root CA, DER-encoded (application/x-x509-ca-cert) |
| POST | /scep?operation=PKIOperation |
— | cli | PKCS#7 request as a raw body (application/x-pki-message or application/octet-stream) or a multipart field message, up to 10 MB. Responds with a PKCS#7 CertRep. |
| POST | /api/scep/challenge |
auth | api | { identifier, expiresIn? } (seconds, default 3600) → { identifier, challengePassword, expiresAt } |
| GET | /api/scep/challenges |
auth | api | { challenges: [{ identifier, expiresAt, used, expired }] } (passwords are not returned) |
| POST | /api/scep/certificate |
auth | cli | { commonName, template?, upn?, subjectAltNames?, challengePassword? } → { commonName, template, upn, certificatePath, method, enterpriseMode, message } |
| GET | /api/scep/certificates |
auth | api | { certificates: [{ commonName, path, created, modified }] } from certificates/scep/ |
| GET | /api/scep/certificates/:commonName |
auth | api | { commonName, certPath, keyPath, info } (openssl x509 -text output); 404 if missing |
| GET | /api/scep/config |
auth | api | Service URLs, supported operations and capabilities |
| GET | /api/scep/enterprise-ca/status |
auth | api | Enterprise CA configuration status |
| GET | /api/scep/templates |
auth | api | User, M365User, Computer, WiFi with their key usages |
| POST | /api/scep/validate-upn |
auth | api | { upn } → { upn, valid, message } |
Challenge passwords are kept in memory, so a restart clears them. See SCEP.md.
All settings routes return 403 unless ENABLE_AUTH=true (ENABLE_OIDC
alone is not enough), and otherwise need a session (401 without one). They use
the api tier.
| Method | Path | Notes |
|---|---|---|
| GET | /api/settings |
Saved settings merged over the running config. Secrets are masked (********; the basic-auth password comes back empty). |
| GET | /api/settings/running |
The configuration this process loaded at startup, masked. |
| POST | /api/settings |
Body: a nested object of the editable keys (for example { "email": { "smtp": { "host": "smtp.dev.local" } } }). Unknown keys are dropped; masked placeholders leave the stored secret unchanged. Merged into config/settings.json. |
| DELETE | /api/settings |
Deletes config/settings.json. |
| GET | /api/settings/export |
config/settings.json as a download, secrets masked. |
| POST | /api/settings/import |
Same body and rules as POST /api/settings. |
Saved settings take effect after a restart. See SETTINGS.md.
| Method | Path | Auth | Tier | Notes |
|---|---|---|---|---|
| GET | /api |
— | general | { name, version, description, endpoints } (see below) |
| GET | /api/health |
— | general | { status: "ok", timestamp, uptime, version } |
| GET | /api/status |
— | general | Public, even with ENABLE_AUTH=true: mkcertInstalled, opensslAvailable, caExists, plus server (running, uptime), ca (exists), features (booleans) and environment (nodeEnv, platform, arch). No paths, pid, memory or versions |
| GET | /api/config/theme |
— | general | { defaultTheme: "dark" | "light" } from THEME_MODE (no success field) |
| GET | /api/rate-limit/status |
— | general | The configured limits as text |
| GET | /api/system |
auth | general | Platform, memory, CPU count, Node version |
| GET | /api/config |
auth | general | Server port and host, auth/OIDC flags, theme |
Unknown paths under /api/ return 404 { success: false, error: "API endpoint not found" }.
GET /api format (changed in 5.0). endpoints is grouped by area
(authentication, certificates, rootCA, notifications, scep,
settings, system). Each key is "METHOD /path" and each value is a short
description:
{
"success": true,
"name": "mkcert Web UI API",
"version": "5.0.0",
"endpoints": {
"certificates": {
"GET /api/certificates": "List all certificates",
"POST /api/certificates/:folder/:certname/restore": "Restore an archived certificate (?swap=true|false)"
}
}
}Before 5.0 the keys were paths and the values started with the method
("/api/certificates": "GET - …").
These return 404: GET /api/files, GET /api/file/:filename/content,
GET /download/:filename, GET /api/certificate/:filename,
DELETE /api/certificate/:filename and GET /api/commands. Use the
folder-aware /api/download/* and /api/certificates/:folder/:certname routes.