Skip to content
UoGSoEPublic

About

Self-service CSR submission API with bearer token auth

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

csr-api

HTTP API for submitting Certificate Signing Requests. POST a CSR, the API validates it, saves it to disk, and records the submission.

What it does

You POST a hostname and a base64-encoded CSR. The API validates the CSR (checks it's valid PEM with a CN or SANs), saves it to disk organised by token owner, and records the submission in SQLite. The actual certificate issuance is handled by whatever existing tooling you have. Once that tooling has issued the certificate, the API serves it back from a directory or URL you point it at, and marks the request complete when it does.

Submitting a CSR and checking its status need a bearer token; downloading the certificate does not. Tokens are SHA-256 hashed before storage, so the raw token only appears once at creation time. The token's owner label organises CSRs on disk; each owner gets their own directory.

Compiles to a single static binary with an embedded SQLite database. Nothing else to install.

Prerequisites

  • Go 1.27 or later

Getting started

Grab a binary from the releases page - rename it and chmod +x - you're good to go (no pun intended).

Install with Go:

go install github.com/UoGSoE/csr-api/cmd/csr-api@latest

Or clone the repo and build:

git clone [email protected]:UoGSoE/csr-api.git
cd csr-api
go build ./cmd/csr-api

Create a token for API access:

./csr-api create-token "alice"

This prints the raw token (save it, you won't see it again) and an 8-character prefix you can use to revoke it later.

Start the server:

./csr-api serve

By default this listens on :8443. CSRs are saved to data/csrs/.

Usage

Submit a CSR

curl -X POST http://localhost:8443/submit-csr \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"hostname": "app.example.ac.uk", "b64_csr": "<base64-encoded PEM CSR>"}'

The response confirms the submission:

{
  "hostname": "app.example.ac.uk",
  "submitted_by": "alice",
  "status": "submitted",
  "message": "CSR received and saved. It will be processed shortly."
}

The CSR is saved to disk at data/csrs/alice/app.example.ac.uk.csr.

The hostname must be a lowercase DNS name (letters, digits, hyphens and dots). The CSR must ask for that one name only: its CN and every SAN have to match the hostname, so wildcards and extra names get a 400. Resubmitting for the same hostname replaces the earlier CSR, unless that request is currently processing, in which case you get a 409.

Check status

curl http://localhost:8443/status/app.example.ac.uk \
  -H "Authorization: Bearer <token>"

Returns the current state of the submission:

{
  "hostname": "app.example.ac.uk",
  "submitted_by": "alice",
  "status": "submitted",
  "created_at": "2026-09-03T10:00:00Z",
  "completed_at": null
}

A request starts as submitted. Nobody has to tell the API when the certificate is issued: every status check looks in the cert store (see Where certificates come from) and, if the certificate is there and was issued for this CSR, the request becomes complete and completed_at is set. The other two states, processing and failed, are only ever set by hand with set-status, for when someone wants to say "we've picked this up" or "this is never going to happen". A matching certificate turning up later still wins and the request becomes complete.

Download the certificate

curl http://localhost:8443/certs/app.example.ac.uk.pem -o app.example.ac.uk.pem

No token needed. The thing downloading is usually a cron job on the server itself, checking for a new certificate and kicking Apache when one turns up, and it should not need an API token sitting on every box for that. Certificates are public in any case.

A 404 means it has not been issued yet, so a script can simply retry until it gets a 200. Serving the certificate marks the request complete, the same as a status check would. A 502 means the cert store itself could not be reached, which is worth someone looking at but is not the requester's problem to fix.

The certificate is checked against the newest CSR submitted for that hostname. One issued for an earlier key counts as not issued yet, so a resubmission never gets the old certificate back. Hostnames nobody has submitted a CSR for are answered from the database without touching the cert store, so random probing costs nothing upstream.

If whoever issues the certificates also publishes other formats, ask for them by extension: .pem, .crt, .cer and .der are served. The .pem is the one checked against the CSR, so the other formats are only handed out once that check has passed, and a 404 saying the certificate is not available in that format means the .pem is there but the sibling file is not.

Token management

./csr-api create-token "alice"       # generate a new token
./csr-api list-tokens                # show all tokens as CSV
./csr-api revoke-token <prefix>      # revoke by 8-char prefix

In the unlikely event that two tokens share the same 8-character prefix, revoke-token will list the matches and ask you to pick which one to revoke.

Setting a status by hand

./csr-api set-status app.example.ac.uk processing
./csr-api set-status app.example.ac.uk failed --error "CAA record forbids issuance"
./csr-api set-status app.example.ac.uk complete

Updates the newest request for that hostname and prints what changed. If more than one token owner has asked for the same hostname, --owner picks whose. Setting complete stamps completed_at and clears any error; setting anything else clears completed_at.

create-token refuses a name that would share a directory with an existing active token: Physics Dept and physics-dept both become physics-dept/ on disk, and two tokens landing there usually means two people picked up the same request. Revoke the old token or pick a different name such as Physics Dept 2.

Example script

This script generates a private key and CSR with openssl, then submits it to the API.

#!/usr/bin/env bash
set -euo pipefail

# --- Configuration ---
API_URL="https://csr-api.example.ac.uk"
API_TOKEN="your-bearer-token-here"
HOSTNAME="app.example.ac.uk"
KEY_FILE="${HOSTNAME}.key"
CSR_FILE="${HOSTNAME}.csr"

# --- CSR defaults (change these to match your organisation) ---
COUNTRY="GB"
STATE="Scotland"
LOCALITY="Glasgow"
ORGANIZATION="University of Glasgow"
ORG_UNIT="School of Engineering"
KEY_SIZE=4096

# --- Generate a private key and CSR ---
openssl genrsa -out "$KEY_FILE" "$KEY_SIZE"
openssl req -new -key "$KEY_FILE" -out "$CSR_FILE" \
  -subj "/C=${COUNTRY}/ST=${STATE}/L=${LOCALITY}/O=${ORGANIZATION}/OU=${ORG_UNIT}/CN=${HOSTNAME}"

echo "Generated key: ${KEY_FILE}"
echo "Generated CSR: ${CSR_FILE}"

# --- Base64-encode the CSR and submit ---
# -w0 avoids line wrapping (Linux). On macOS, base64 doesn't wrap by default.
B64_CSR=$(base64 -w0 < "$CSR_FILE" 2>/dev/null || base64 < "$CSR_FILE" | tr -d '\n')

RESPONSE=$(curl -s -X POST "${API_URL}/submit-csr" \
  -H "Authorization: Bearer ${API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d "{\"hostname\": \"${HOSTNAME}\", \"b64_csr\": \"${B64_CSR}\"}")

echo "$RESPONSE" | python3 -m json.tool

echo ""
echo "CSR submitted. Check status with:"
echo "  curl ${API_URL}/status/${HOSTNAME} -H 'Authorization: Bearer ${API_TOKEN}'"
echo "Download the certificate once it is issued (404 until then) with:"
echo "  curl ${API_URL}/certs/${HOSTNAME}.pem -o ${HOSTNAME}.pem"

Configuration

Flag Env var Default Description
--addr CSR_API_ADDR :8443 Listen address
--csrs-dir CSR_API_CSRS_DIR data/csrs Where submitted CSRs are saved
--cert-store CSR_API_CERT_STORE data/certs Directory or base URL where issued certificates appear as {hostname}.pem
--db-path CSR_API_DB_PATH data/certs.db SQLite database path
--allowed-domains CSR_API_ALLOWED_DOMAINS any Comma-separated domains that CSRs are accepted for; anything else gets a 403
--daily-rate-limit CSR_API_DAILY_RATE_LIMIT none Max CSR submissions per token in a 24-hour window; over that gets a 429

Restricting domains

By default the API accepts a CSR for any hostname. In practice the people issuing certificates are usually happy to hand out my-server.school.example.ac.uk without a conversation but will want to ask a few questions about www.project.example.org, so restrict self-service to the boring case:

./csr-api serve --allowed-domains example.ac.uk,related.ac.uk

A hostname is accepted if it is one of those domains or a subdomain of one. Anything else gets a 403 and the requester can go through whatever manual process you already have, which keeps the paper trail for the unusual ones. The server logs what it is allowing at startup either way:

level=INFO msg="allowing cert requests" domains=example.ac.uk,related.ac.uk
level=INFO msg="allowing cert requests" domains=any hint="use --allowed-domains to restrict this"

Rate limiting per token

The nginx config further down limits requests per client IP, which stops strangers hammering the box but does nothing about one valid token stuck in a loop. For that, cap submissions per token:

./csr-api serve --daily-rate-limit 20

Each token gets that many CSR submissions in a 24-hour window, counted from its first submission and kept in memory, so a restart clears it. Over the limit gets a 429 with a Retry-After header. Only submissions that pass validation count: a broken CSR or a status check does not use up the budget. Unset means no limit, and the server says so at startup:

level=INFO msg="rate limiting submissions" per_token_per_day=20
level=INFO msg="rate limiting submissions" limit=none hint="use --daily-rate-limit to restrict this"

Where certificates come from

The API never issues certificates. It looks for them in a cert store, which is either a directory or a base URL:

./csr-api serve --cert-store /srv/certs
./csr-api serve --cert-store https://certs.example.ac.uk/certs/

Either way the certificate for app.example.ac.uk is expected at {cert-store}/app.example.ac.uk.pem, and its absence (no such file, or a 404 from the URL) means it has not been issued yet. Other formats live alongside it under the same name with their own extension, such as app.example.ac.uk.der. The directory form suits a workflow where whoever issues certificates scps them onto this box. The URL form suits one where they already publish certificates on a web server and would rather not change anything; the API fetches the certificate and serves it on, so requesters only ever need to reach this one host. The default is data/certs, next to the CSRs.

Before a certificate is served or a request marked complete, the API checks that the certificate's public key matches the CSR on record. That is what stops a resubmitted CSR being satisfied by a certificate issued for the old key. The .pem may be a bare certificate or a full chain in any order.

The server says where it is looking at startup:

level=INFO msg="looking for issued certificates" cert_store=data/certs

Logging

The API uses Go's structured logging (slog). Every log line is machine-parseable with level, msg, and contextual fields.

  • INFO: normal operations (startup, CSR submissions, certificates issued, shutdown)
  • WARN: expected but notable events (rejected auth, minor failures)
  • ERROR: something is actually broken (DB errors, file write failures)

Example output:

time=2026-03-19T20:00:00.000Z level=INFO  msg="starting server" addr=:8443
time=2026-03-19T20:01:30.000Z level=INFO  msg="csr submitted" hostname=app.example.ac.uk submitted_by=alice path=data/csrs/alice/app.example.ac.uk.csr
time=2026-03-19T20:02:00.000Z level=WARN  msg="auth rejected" prefix=8a5a0868
time=2026-03-19T20:05:00.000Z level=INFO  msg="shutting down..."

If you're feeding logs into a central system, grep for level=ERROR to catch things that need attention. level=WARN with msg="auth rejected" is worth monitoring if you want to spot brute-force attempts. The prefix field identifies which token was tried without leaking the secret.

Running behind a reverse proxy

In production you'll want a reverse proxy in front of the API to handle TLS termination (and optionally rate limiting). Bind the API to localhost so it's only reachable through the proxy:

./csr-api serve --addr 127.0.0.1:8443

Or via environment variable:

CSR_API_ADDR=127.0.0.1:8443 ./csr-api serve

Caddy

Caddy handles TLS automatically. If the hostname has a public DNS record, Caddy will fetch a Let's Encrypt certificate with no extra config. For an internal hostname where you already have a cert, point to the files explicitly.

Automatic TLS (public DNS):

csr-api.example.ac.uk {
	reverse_proxy 127.0.0.1:8443
}

Manual TLS (internal cert):

csr-api.example.ac.uk {
	tls /etc/ssl/certs/csr-api.pem /etc/ssl/private/csr-api.key
	reverse_proxy 127.0.0.1:8443
}

That's the entire file. Save it as Caddyfile and run caddy run.

Rate limiting in Caddy requires the external caddy-ratelimit module, which means building Caddy with xcaddy. If you need rate limiting and would rather not maintain a custom Caddy build, nginx (below) has it built in.

nginx

nginx needs a certificate provided; it won't fetch one for you. The config below terminates TLS, rate-limits to 10 requests/second per client IP (with a small burst allowance), and proxies to the API.

/etc/nginx/sites-available/csr-api:

# Rate limit zone: 10 req/s per client IP, shared across workers.
# The zone uses 10MB of shared memory (~160,000 unique IPs).
limit_req_zone $binary_remote_addr zone=csr_api:10m rate=10r/s;

server {
    listen 443 ssl;
    server_name csr-api.example.ac.uk;

    ssl_certificate     /etc/ssl/certs/csr-api.pem;
    ssl_certificate_key /etc/ssl/private/csr-api.key;

    # Apply the rate limit. burst=20 queues short spikes rather than
    # rejecting them outright; nodelay forwards queued requests
    # immediately once a slot opens.
    limit_req zone=csr_api burst=20 nodelay;

    # Return a JSON error body on 429 so API clients get something
    # machine-readable rather than an nginx HTML page.
    limit_req_status 429;
    error_page 429 = @rate_limited;
    location @rate_limited {
        default_type application/json;
        return 429 '{"error": "rate limit exceeded, try again shortly"}';
    }

    location / {
        proxy_pass http://127.0.0.1:8443;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name csr-api.example.ac.uk;
    return 301 https://$host$request_uri;
}

Enable the site and reload:

ln -s /etc/nginx/sites-available/csr-api /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Tuning the rate limit: 10 req/s with a burst of 20 is generous for a CSR submission API. If you want to lock it down further, rate=2r/s burst=5 would be plenty for normal use while still catching runaway scripts or brute-force attempts against the bearer tokens.

Running tests

go test ./...

Releases

Binaries for Linux, macOS, and Windows (amd64 and arm64) get published when you push a version tag:

git tag v0.2.0
git push origin v0.2.0

Binaries and SHA-256 checksums are attached to the GitHub release.

Contributing

Fork the repo and clone it:

git clone [email protected]:<you>/csr-api.git
cd csr-api
go mod download

Make your changes, run go test ./... and go vet ./..., then open a pull request. CI runs the same plus gofmt and govulncheck.

Licence

MIT. See LICENSE for details.

About

Self-service CSR submission API with bearer token auth

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages