ecdsa.com

REST API v1 · free · no keys · CORS enabled

Free Signature API

The checks behind the ecdsa.com tools, callable from scripts and CI: verify ECDSA signatures, convert DER ⇄ raw, decode and verify ES256/ES384/ES512 JWTs, decode X.509 certificates, grade a domain's TLS signature health. Plain JSON over HTTPS — https://ecdsa.com/api/v1

$ curl -s https://ecdsa.com/api/v1

Machine-readable spec: /api/v1/openapi.json (OpenAPI 3.1)

Free, no keys

No signup, no tokens, no quotas to manage. Send a request, get JSON back.

60 requests/min per IP

Generous for scripts and CI. The health endpoint adds a 10/min budget because it scans outward. Limits are per server instance.

CORS open

Access-Control-Allow-Origin: * on every response — call it straight from a browser, a doc page or a serverless function.

Never send private keys

Requests containing a PEM PRIVATE KEY block or a JWK with "d" are rejected with private_key_rejected. For anything secret, use the local browser tools — they never transmit your input.

Nothing logged

Request bodies are parsed in memory and never written to logs.

Endpoints

All endpoints live under https://ecdsa.com/api/v1. POST bodies are JSON (≤100KB); every response is JSON; errors come back as { "error": { "code", "message" } } with a matching HTTP status. The examples below are real and runnable — the sample signature verifies, the sample JWT checks out against its key.

POST

/api/v1/verify

Verify an ECDSA signature

Checks a signature over a message (or a precomputed digest) against a public key. Curves: P-256, P-384, P-521, secp256k1. The signature format (ASN.1 DER or raw r‖s) and its encoding (hex, base64, base64url) are auto-detected. A signature that simply does not verify returns valid: false with HTTP 200 — only unparseable input is a 400.

FieldTypeRequiredDescription
publicKeystringyesPEM "PUBLIC KEY" (SPKI), an EC JWK as JSON, or a bare SEC1 point in hex/base64.
messagestringyesThe signed message.
signaturestringyesDER or raw r‖s signature; hex, base64 or base64url.
hash"SHA-256" | "SHA-384" | "SHA-512"noDigest applied to the message. Default SHA-256; ignored when messageIsDigest is true.
curve"auto" | "P-256" | "P-384" | "P-521" | "secp256k1"noForce a curve. Default "auto" — inferred from the key.
messageEncoding"utf8" | "hex" | "base64"noHow the message string encodes bytes. Default utf8.
messageIsDigestbooleannoWhen true, the message bytes are used as the digest directly (no hashing). Default false.

Response: { valid, curve, curvesTried, keySource, signatureFormat, details: { r, s, highS }, notes[] }.

curl
curl -s https://ecdsa.com/api/v1/verify \
  -H 'content-type: application/json' \
  -d '{"publicKey":"-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----","message":"The quick brown fox jumps over the lazy dog","signature":"3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"}'
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/verify", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----",
    "message": "The quick brown fox jumps over the lazy dog",
    "signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
  }),
});
console.log(await res.json());
Python (requests)
import requests

r = requests.post(
    "https://ecdsa.com/api/v1/verify",
    json={
        "publicKey": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4+8TCP3Hop/Acb8L7reksO9flyt1\nsUhvEqUs7a45RVDRDtAIZdTnOJcAhTCQvdzUBO4KUyxSRcZm6ro+2RGfSQ==\n-----END PUBLIC KEY-----",
        "message": "The quick brown fox jumps over the lazy dog",
        "signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
    },
)
print(r.json())

Try it

POST/api/v1/verify
POST

/api/v1/convert

Convert DER ⇄ raw r‖s

Decodes a signature in either format and returns both encodings, plus r, s and the high-S flag. Warnings report DER canonicality problems (BER lengths, missing 0x00 pads, trailing sighash bytes) and curve-ambiguity notes. normalize: "low-s" replaces a high-S s with n − s — the canonical form Bitcoin-family consensus rules require.

FieldTypeRequiredDescription
signaturestringyesDER or raw r‖s signature; hex, base64 or base64url (auto-detected).
curve"P-256" | "P-384" | "P-521" | "secp256k1"noCurve for component sizing and the high-S check. Inferred from the length when omitted (a 64-byte raw signature is ambiguous between P-256 and secp256k1).
normalize"low-s"noReplace a high-S s with its low-S equivalent n − s.

Response: { inputFormat, curve, der: { hex, base64 }, raw: { hex, base64, base64url }, r, s, highS, warnings[] }.

curl
curl -s https://ecdsa.com/api/v1/convert \
  -H 'content-type: application/json' \
  -d '{"signature":"3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"}'
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/convert", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    "signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
  }),
});
console.log(await res.json());
Python (requests)
import requests

r = requests.post(
    "https://ecdsa.com/api/v1/convert",
    json={
        "signature": "3045022100df542bb71e0e3019086f1b1ba51f1aa0ec5e6afca05c3f5a7f4f02f5b7c39d24022030c59f1ac6d8c6893fb2a5ac8dfd9388d31ebd3382510dcfb7f28f9182eb1131"
    },
)
print(r.json())

Try it

POST/api/v1/convert
POST

/api/v1/jwt/decode

Decode a JWT (no signature check)

Decodes a compact JWS token into header and payload, enriches the exp / iat / nbf time claims with UTC and relative renderings, and classifies the algorithm. The signature is NOT verified — the response says verified: false explicitly. Decoding a token proves nothing about who issued it.

FieldTypeRequiredDescription
tokenstringyesThe compact JWS token. A leading "Bearer " and whitespace are stripped.

Response: { header, payload, signaturePresent, signatureBytes, timeClaims: { exp, iat, nbf, status }, algInfo, verified: false, note }.

curl
curl -s https://ecdsa.com/api/v1/jwt/decode \
  -H 'content-type: application/json' \
  -d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"}'
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/jwt/decode", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"
  }),
});
console.log(await res.json());
Python (requests)
import requests

r = requests.post(
    "https://ecdsa.com/api/v1/jwt/decode",
    json={
        "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw"
    },
)
print(r.json())

Try it

POST/api/v1/jwt/decode
POST

/api/v1/jwt/verify

Verify an ES256/ES384/ES512 JWT

Verifies the token's ECDSA signature against a public key. Only the ES* family is supported — HS*, RS*/PS*, EdDSA, ES256K and "none" return 400 with code unsupported_algorithm and an explanation. valid reflects the cryptographic check only; expired tokens are flagged in explanations but still report their signature honestly.

FieldTypeRequiredDescription
tokenstringyesCompact JWS token with alg ES256, ES384 or ES512.
publicKeystringyesPEM "PUBLIC KEY" (SPKI), an EC JWK as JSON, or a bare SEC1 point in hex/base64.

Response: { valid, alg, explanations[] }.

curl
curl -s https://ecdsa.com/api/v1/jwt/verify \
  -H 'content-type: application/json' \
  -d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw","publicKey":"{\n  \"kty\": \"EC\",\n  \"crv\": \"P-256\",\n  \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n  \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n  \"kid\": \"copper-example-1\"\n}"}'
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/jwt/verify", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw",
    "publicKey": "{\n  \"kty\": \"EC\",\n  \"crv\": \"P-256\",\n  \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n  \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n  \"kid\": \"copper-example-1\"\n}"
  }),
});
console.log(await res.json());
Python (requests)
import requests

r = requests.post(
    "https://ecdsa.com/api/v1/jwt/verify",
    json={
        "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImNvcHBlci1leGFtcGxlLTEifQ.eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlLmNvbSIsInN1YiI6InVzZXJfNDIiLCJhdWQiOiJlY2RzYSIsImlhdCI6MTc4NzE0MDgwMCwiZXhwIjo0MDcwOTA4ODAwfQ.fQpTQxkahGRlZp-U71-xNk68mpZH23dhn9QoysMLmZfBx_e-vd9sCgh7sJ4eXu0-WbQSNqzmVPyUKErCJx4DHw",
        "publicKey": "{\n  \"kty\": \"EC\",\n  \"crv\": \"P-256\",\n  \"x\": \"A4KIXkvTVuOzkR5Mzgsy8n_VSSIlephF1ViMxDYTUx4\",\n  \"y\": \"Py8AMAM_6yWsOTbv8eHCvKDfObpZmTHy5P4Jz30iMuU\",\n  \"kid\": \"copper-example-1\"\n}"
    },
)
print(r.json())

Try it

POST/api/v1/jwt/verify
POST

/api/v1/cert/decode

Decode an X.509 certificate or chain

Accepts PEM with one or more CERTIFICATE blocks (paste a whole chain) or a single DER certificate as base64/hex. Each certificate comes back as a structured summary — subject, issuer, validity, key and signature algorithms, SAN, key usage, SHA-256 fingerprint — plus assessments: weak signature hashes, expiry, CA/Browser Forum lifetime limits, post-quantum readiness.

FieldTypeRequiredDescription
certificatestringyesPEM CERTIFICATE block(s) or one DER certificate as base64/hex.

Response: { certificates: [ …summary with assessments[] ], notes[] }.

curl
curl -s https://ecdsa.com/api/v1/cert/decode \
  -H 'content-type: application/json' \
  -d '{"certificate":"-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"}'
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/cert/decode", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    "certificate": "-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"
  }),
});
console.log(await res.json());
Python (requests)
import requests

r = requests.post(
    "https://ecdsa.com/api/v1/cert/decode",
    json={
        "certificate": "-----BEGIN CERTIFICATE-----\nMIICFjCCAbugAwIBAgIIBKGyw9Tl9gcwCgYIKoZIzj0EAwIwSDEXMBUGA1UEAxMO\nZGVtby5lY2RzYS5jb20xIDAeBgNVBAoTF0VDRFNBLmNvbSBTaWduYXR1cmUgTGFi\nMQswCQYDVQQGEwJVUzAeFw0yNjAxMTUwMDAwMDBaFw0yNzAxMTUwMDAwMDBaMEgx\nFzAVBgNVBAMTDmRlbW8uZWNkc2EuY29tMSAwHgYDVQQKExdFQ0RTQS5jb20gU2ln\nbmF0dXJlIExhYjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC\nAASOtEAsuQh2j8SNY/hVbkvFlXwHUzLXZjcTsaEcuwwvXCVsldoaP1Orr1h16iU+\nqjEAP2gmZSM2XbSN3B+h59Lko4GOMIGLMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/\nBAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAtBgNVHREEJjAk\ngg5kZW1vLmVjZHNhLmNvbYISd3d3LmRlbW8uZWNkc2EuY29tMB0GA1UdDgQWBBTp\nEL0Utu1R8qzHZ9IWC2WP789lqTAKBggqhkjOPQQDAgNJADBGAiEAjad1xoTy9SXd\nd8DnZMIA4jkASCGkYSc1R9io7mj83NcCIQDE0GDEHRLcXxZVdZgDcJjSPRx6cLqM\n4v4zJDEFLZ5reA==\n-----END CERTIFICATE-----"
    },
)
print(r.json())

Try it

POST/api/v1/cert/decode
GET

/api/v1/health/{domain}

Domain TLS signature health

JSON version of the Signature Health report: connects to the domain on port 443, examines the served chain, algorithms, protocol versions and lifetimes, and returns a letter grade with findings. Results are cached for one hour. Only public DNS hostnames are accepted — IP addresses, single-label names and private/reserved addresses are refused. Because the scan makes outbound connections, this endpoint has an extra limit of 10 requests/min per IP.

FieldTypeRequiredDescription
domainstring (path)yesPublic DNS hostname, e.g. "github.com".

Response: { domain, grade: { letter, score }, findings[], scannedAt, cached, cacheTtlSeconds, reportUrl }.

curl
curl -s https://ecdsa.com/api/v1/health/github.com
JavaScript (fetch)
const res = await fetch("https://ecdsa.com/api/v1/health/github.com");
console.log(await res.json());
Python (requests)
import requests

r = requests.get("https://ecdsa.com/api/v1/health/github.com")
print(r.json())

Try it

GET/api/v1/health/github.com
A fresh (uncached) scan can take several seconds.

Rate limits

60 requests per minute per IP across the whole API, plus an extra 10 requests per minute budget for /health/{domain} (it opens outbound TLS connections). Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; exceeding a limit returns 429 with a Retry-After header. Honest fine print: counters are in-memory and per server instance, so under horizontal scaling the effective allowance can be somewhat higher — treat the documented numbers as the intended budget, not a hard ceiling. If you need more for something legitimate, back off politely and cache on your side; the health endpoint is already cached for an hour per domain.

Errors

Every error is JSON with a machine-readable code and a human-readable message that says exactly what was wrong with the input — the same explanations the browser tools show.

CodeHTTPMeaning
invalid_json400The body is not a JSON object.
missing_field400A required field is absent or empty.
invalid_input400A field could not be parsed — the message explains exactly why.
private_key_rejected400The request contains private-key material. Never send private keys anywhere.
unsupported_algorithm400/jwt/verify received a non-ES* token.
invalid_domain400/health received something other than a public DNS hostname.
scan_dns_error / scan_private_address400The domain does not resolve, or resolves to a private/reserved address.
payload_too_large413The body exceeds 100KB.
rate_limited429Too many requests — see Retry-After.
scan_unreachable / scan_tls_error502The domain could not be reached, or the TLS handshake failed.
scan_timeout504The scan timed out.
internal_error500Unexpected server error.

Changelog

v1.0.02026-08-20

Initial public release: /verify, /convert, /jwt/decode, /jwt/verify, /cert/decode, /health/{domain} and the OpenAPI 3.1 spec. The API is versioned in the path; breaking changes would ship as /api/v2 — v1 responses may gain fields but will not lose or change them.