A small, CA-agnostic OCSP responder (RFC 6960) written in Go.
Point it at a CRL file, a URL, or a CA's REST API, give it a delegated signing certificate, and it answers OCSP queries about your certificates. It ships as a single static binary or a 19 MB container image, with no database and no runtime dependencies.
- A standalone OCSP responder you run next to an existing CA
- CA-agnostic — it reads revocation state from a CRL or an HTTP API, so it doesn't care which software issued your certificates
- Read-only — it answers status queries and nothing else
- Not a CA — it does not issue certificates
- Not a certificate manager
- Not a revocation tool — revoking happens in your CA; this service only reports what the CA already decided
Probably worth being upfront: if you need a battle-tested OCSP responder for a public PKI, use Boulder (what Let's Encrypt runs) or OpenXPKI. They have years of production exposure that this does not.
This project is aimed at a narrower case: an internal or homelab PKI where you already have a CA that emits a CRL, you want OCSP without deploying a full PKI suite, and you'd like the whole thing to be a config file and one binary. If that's you, it should be a comfortable fit. If you're securing something that matters to the public internet, reach for the established options.
The repository ships a docker-compose.yaml:
git clone https://github.com/kuveris/ocsp.git
cd ocsp
# place your certificates in ./certs and edit ./config/ocsp-responder.yaml
docker compose up # or: make upThis pulls the published image from GHCR. To build from local source instead,
use make dev (equivalently docker compose -f docker-compose.dev.yaml up --build).
Set OCSP_PORT to bind a host port other than 8080, and OCSP_IMAGE to pin a
release tag rather than latest.
go install github.com/kuveris/ocsp/cmd/ocsp-responder@latest
ocsp-responder --config /etc/ocsp-responder/ocsp-responder.yamlOr build from source:
make build
./ocsp-responder --config config/ocsp-responder.yamlFor a long-running install, see the unit file in
examples/systemd/.
curl http://localhost:8080/health{
"signer_expires_in_days": 312,
"signer_expiry_status": "ok",
"signer_valid": true,
"source": "file",
"source_healthy": true,
"status": "ok"
}Then query a real certificate with OpenSSL:
openssl ocsp -issuer intermediate-ca.crt -cert client.crt \
-url http://localhost:8080 -resp_textThree files, and the responder validates all of them at startup — it refuses to boot on a misconfiguration rather than serving bad answers.
| File | Requirement |
|---|---|
signer.cert_file |
Must carry extendedKeyUsage = OCSPSigning, and must be signed by the issuer below |
signer.key_file |
The matching private key: PKCS#8 (RSA or ECDSA) or PKCS#1 (RSA). SEC1 EC keys (BEGIN EC PRIVATE KEY, from openssl ecparam -genkey) are not accepted — convert with openssl pkcs8 -topk8 -nocrypt |
signer.issuer_cert_file |
The CA that issued the certificates you're answering for. Must be a CA certificate with the keyCertSign key usage |
The signing certificate is a delegated responder: the issuing CA signs it, so clients trust its answers without the CA's own key ever touching this service.
Generating one with OpenSSL — first an extensions file:
# ocsp-signer.cnf
[ ocsp_signer ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = critical, OCSPSigningThen issue it from your intermediate CA:
openssl req -newkey rsa:3072 -nodes \
-keyout certs/ocsp-signer.key \
-out certs/ocsp-signer.csr \
-subj "/CN=OCSP Responder"
openssl x509 -req -in certs/ocsp-signer.csr \
-CA certs/intermediate-ca.crt -CAkey certs/intermediate-ca.key \
-CAcreateserial -days 365 \
-extfile ocsp-signer.cnf -extensions ocsp_signer \
-out certs/ocsp-signer.crtWith step-ca, the equivalent is a certificate issued from a provisioner
configured for OCSP signing.
A fully annotated example lives at
config/ocsp-responder.yaml.
| Field | If omitted | Description |
|---|---|---|
server.listen_addr |
0.0.0.0:8080 |
Address to listen on |
server.tls.enabled |
false |
Serve OCSP over HTTPS |
server.tls.cert_file / key_file |
— | Manual TLS certificate and key |
server.tls.min_version |
1.2 |
Minimum TLS version — 1.3 selects TLS 1.3, anything else is 1.2 |
server.tls.acme_host |
— | Hostname for an automatic ACME certificate |
server.tls.acme_ca_url |
— | ACME directory URL, for internal CAs |
server.tls.acme_cache_dir |
/var/lib/ocsp-responder/acme |
Where ACME certificates are persisted. Startup fails if it is not writable |
signer.cert_file |
required | OCSP delegated signing certificate |
signer.key_file |
required | Signing private key |
signer.issuer_cert_file |
required | Issuer of the certificates being checked |
signer.response_validity |
required | Response validity window, sets nextUpdate (e.g. 24h) |
source.type |
required | file, http, or static |
source.file.expiry_grace |
strict | How long a CRL stays usable past its NextUpdate. Empty or 0s means an expired CRL is treated as unhealthy |
cache.enabled |
false |
In-memory response cache. The example config enables it |
cache.ttl |
required | Maximum cache entry lifetime (e.g. 1h); entries expire sooner at the signed response nextUpdate. Validated even when disabled |
cache.max_entries |
0 (cache inert) |
Cache size cap. 0 silently disables caching |
logging.level |
info |
debug, info, warn, error |
logging.format |
text |
json selects JSON; any other value is text |
Config is validated on load and the process exits on anything invalid, so a
typo surfaces at startup rather than in production. That covers both bad values
and misspelled field names — an unknown key is rejected by name rather than
silently ignored, so lissten_addr fails the load instead of quietly reading
as "unset".
One of these is easy to trip over: the cache is off unless you set both
cache.enabled: true and a non-zero cache.max_entries. Starting from the
shipped example config avoids it.
If you enable ACME, the certificate cache must be writable and must survive
restarts — the shipped Compose stacks mount a named volume at
/var/lib/ocsp-responder for exactly this, and the systemd example uses
StateDirectory=. Without persistence the responder re-orders a certificate on
every restart, which exhausts the CA's duplicate-certificate rate limit and then
leaves it unable to serve TLS at all. Startup fails loudly if the directory is
not writable rather than degrading to an in-memory cache.
OCSP is served over plain HTTP by design — responses are signed, so the transport doesn't need to be confidential. TLS is available if you want it, but it is not what makes the answers trustworthy.
Reads a CRL in PEM or DER (auto-detected) and answers from its revocation entries. Reloads automatically when the file changes.
source:
type: "file"
file:
crl_path: "certs/ca.crl" # local path or an http(s):// URL
reload_interval: "5m"crl_path also accepts an HTTP(S) URL, in which case the CRL is downloaded and
refreshed on every reload_interval.
The CRL's signature is verified against signer.issuer_cert_file before any of
its entries are trusted, so a swapped or corrupted CRL is rejected instead of
being served.
A CRL outside its validity window is not served. Both ends are checked live
on every lookup. A CRL whose ThisUpdate is more than five minutes in the
future (post-dated, or a host clock that is behind) takes the source unhealthy.
A CRL past its NextUpdate plus any configured expiry_grace does the same,
and answers become unknown rather than a stale good — whether it was already
expired at startup or expires later while the file on disk never changes. This
matters because the failure is otherwise invisible: if publication stops, the
file never changes, so nothing detects that the data is obsolete while
certificates revoked since then are still reported valid. A CRL outside its
validity window at startup is a transient condition, not a fatal error: the
responder starts, answers unknown, and recovers automatically when the CRL
becomes usable — a routine publication delay should not become a crash loop.
If your CA publishes late, expiry_grace widens the window rather than taking
the responder down at the moment NextUpdate passes:
source:
type: "file"
file:
crl_path: "certs/ca.crl"
reload_interval: "5m"
expiry_grace: "30m" # default is strictQueries your CA directly. The response mapping is configuration, not code, so most CA APIs can be supported without patching anything.
source:
type: "http"
http:
base_url: "https://ca.example.local:9000"
root_cert_file: "certs/root-ca.crt" # optional: pin the CA's TLS root
timeout: "10s"
retry_max: 3 # default 3
retry_backoff: "500ms" # default 500ms
cache_ttl: "5m"
response_mapping:
path_template: "/1.0/certificates/{serial}"
status_field: "status"
good_values: ["active", "valid"]
revoked_values: ["revoked", "suspended"]{serial} is replaced with the certificate serial from the OCSP request,
formatted as uppercase hexadecimal with no leading zeros. A CA API that
expects decimal or lowercase will 404, which the source maps to unknown for
every certificate.
Anything not matching good_values or revoked_values becomes unknown.
Returns the same status for every certificate. For testing only.
source:
type: "static"
static:
status: "good" # good | revoked | unknownExport the CRL and use the file source:
step ca crl --out certs/ca.crlOr query the certificate API directly with the http source:
source:
type: "http"
http:
base_url: "https://ca.example.local:9000"
response_mapping:
path_template: "/1.0/certificates/{serial}"
status_field: "status"
good_values: ["active", "valid"]
revoked_values: ["revoked"]openssl ca -gencrl -out certs/ca.crl.pem -config openssl.cnfUse the file source — PEM and DER are both accepted.
Any CA that exposes certificate status over HTTP works with the http source
and a suitable response_mapping. Any CA that publishes a CRL works with the
file source. Between the two, most setups are covered.
| Endpoint | Description |
|---|---|
POST / |
DER-encoded OCSP request in the body → signed OCSP response |
GET /{encoded} |
Base64-encoded OCSP request in the path, per RFC 6960 A.1.1 |
GET /health |
Health check — 200 when healthy, 503 when not |
GET /metrics |
Prometheus metrics |
Requests are capped at 10 KB of DER on both methods — the POST body directly, the GET path at its base64-encoded equivalent. Real OCSP requests are well under 1 KB.
Nonces are not echoed. A client that sends an RFC 6960 §4.4.1 nonce gets a
valid signed response without it — openssl ocsp will print
WARNING: no nonce in response. This is deliberate: a nonce makes every
response unique and therefore uncacheable, which is the same tradeoff Let's
Encrypt makes. The consequence is that a captured response can be replayed
until its nextUpdate, so signer.response_validity bounds that window.
GET follows RFC 6960 Appendix A.1.1 — the URL-encoding of the standard base64
encoding of the DER request. Unpadded standard base64 and both base64url forms
are also accepted, so a client that picks a different variant still works.
GET /health returns 503 and "status": "unhealthy" when the signing
certificate is invalid or expired, or when the status source is unhealthy —
a CRL that failed to load or has expired, or a CA API that stopped answering.
Suitable as a container health check or load-balancer probe.
Both sources are healthy until proven otherwise, so a freshly started responder
reports healthy without needing to serve a request first. For the file source
that is settled at startup, since the CRL is loaded before the server binds; for
the http source the first failed lookup demotes it, and a later success
restores it.
| Metric | Type | Description |
|---|---|---|
ocsp_requests_total{method,status} |
Counter | OCSP requests processed |
ocsp_request_duration_seconds{method} |
Histogram | Request processing time |
ocsp_cache_entries |
Gauge | Responses currently cached |
ocsp_cache_hits_total |
Counter | Cache hits |
ocsp_cache_misses_total |
Counter | Cache misses |
ocsp_signer_days_until_expiry |
Gauge | Days until the signing certificate expires |
ocsp_source_requests_total{source,result} |
Counter | Requests to the status source |
ocsp_source_request_duration_seconds{source} |
Histogram | Status source latency |
ocsp_source_retries_total{source} |
Counter | Status source retries |
ocsp_source_errors_total{source,class} |
Counter | Status source errors by class (timeout, canceled, transport_or_upstream, unmapped) |
ocsp_signer_days_until_expiry is the one worth alerting on. The responder
classifies it internally as OK at 30 days or more, warning at 8–29, and
critical below 8 — an expired signing certificate does not stop the process — it keeps
serving responses that clients reject, which is an outage that looks like
uptime. Easy to forget about for a year.
- Fails closed. When the status source errors, the responder returns
unknown— nevergood. A broken CRL feed cannot silently vouch for a revoked certificate. - Issuer binding is enforced. Incoming requests are checked against the configured issuer's name and key hashes before any lookup happens, so the responder won't answer for a CA it isn't configured to speak for.
- CRLs are verified. A CRL is checked against the configured issuer before
its entries are used, and refused once past its
NextUpdate(seeexpiry_grace) so stale revocation data cannot be served as current. - The signing key is never logged, at any log level.
- Startup validation. Missing EKU, a signer not issued by the configured issuer, a non-CA issuer, or a key that doesn't match the certificate are all startup failures rather than runtime surprises.
- Runs as non-root in the container image, as uid 100.
- Caching is safe. OCSP responses are signed and carry their own validity
window, so caching them cannot forge an answer. Note that eviction at
max_entriesdrops an arbitrary entry, not the oldest or least-used. - Key permissions are your responsibility:
chmod 600, owned by the service user. Key and certificate extensions undercerts/are gitignored (*.key,*.crt,*.pem,*.der,*.crl,*.p12) — other filenames there are not, so avoidprivkeyorsigner.key.bak.
make check # full gate: lint + unit + integration, all under -race
make build # build the binary
make test # unit tests
make integration-test # integration tests
make coverage # coverage summary
make coverage-check # fail if coverage drops below the threshold
make lint # go vet + golangci-lint
make dev # build locally and run via compose
make help # list the available targetsRequires Go 1.25 or newer. make lint additionally needs
golangci-lint v2.
Bug reports and patches are welcome — see CONTRIBUTING.md. For security issues, please follow SECURITY.md rather than opening a public issue.
MIT — see LICENSE.