Per-Election auth_key (External Integrations)
This page documents the auth_key field on the Election object. Itās a niche feature aimed at external integrations that create elections on a userās behalf ā the discord bot is the motivating example. End-user election admins do not interact with it; the BetterVoting web UI does not produce or consume it.
If you are designing or maintaining an external integration that needs to manage elections it created, read this page in full before writing code.
What auth_key is
Election.auth_key is a per-election PEM-encoded RSA public key. When present on an election row, BetterVoting will accept management requests against that election (edit, delete, etc.) only if the request carries a JWT signed by the matching private key.
In practice that means:
- An external client (e.g. a discord bot) generates an RSA keypair on its own host.
- When it creates an election, it includes its public key as
Election.auth_keyin the create payload. - For any later management call, the client signs a short-lived JWT with its private key and sends it in the
custom_id_tokencookie. - The backend verifies the JWT against the electionās stored
auth_keyand populatesreq.userfrom the verified claims.
Voter-side flows (casting ballots, viewing public results) do not use auth_key. Voters continue to authenticate via the standard mechanisms described in Voter Authentication Modes. auth_key is for owner-equivalent operations only.
Why this exists
The platform-managed identity story (Keycloak via id_token cookie) requires each end user to log into BetterVoting. That doesnāt work for integrations where the creator of an election is a bot or service rather than a human with a BetterVoting account. auth_key lets such a service prove it owns an election without going through Keycloak, by holding the private key for that election locally.
It is not a feature you should expose to election admins via the BV web UI. It exists to support trusted external integrations.
Algorithm and key format
- Algorithm: RS256 only. HS256 is rejected at both write time (
electionValidationinpackages/shared/src/domain_model/Election.ts) and verify time (AccountServiceUtils.extractUserFromRequest). - Format: PEM, SPKI public key. The string must contain
-----BEGIN PUBLIC KEY-----. PKCS#1 (-----BEGIN RSA PUBLIC KEY-----) and raw certificate forms are rejected.
A minimal Node generator:
import crypto from 'crypto';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// publicKey ā goes on Election.auth_key
// privateKey ā stays on the integration's host, never sent to BV
A minimal Python generator:
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
private_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
Why RS256 (and not HS256)
HS256 uses one shared secret for both signing and verifying. If we accepted HS256 keys, BetterVoting would have to store the signing secret in its database in order to verify tokens. That means:
- The platform itself holds material that can authenticate as the election owner. Trust-wise, BV becomes part of the integrationās owner-equivalent trust set, not just a relying party.
- Any future read of the DB ā backup leak, log misconfiguration, ransomware, insider access broadening ā exfiltrates active signing keys for every HS256-using integration.
- Collateral channels (TLS terminations, application error logs, Sentry payloads, replicas,
pg_dumpartifacts) all transit the live secret.
With RS256, the platform only ever sees public keys. The integrationās private key never leaves the integrationās host. A future BV-side compromise yields no useful material against past or future elections.
The cost on the integration side is essentially zero: keypair generation is one function call, and PEM is one extra newline character compared to a hex string. We treat RS256 as the only supported algorithm.
Lifecycle and storage on the integration side
- Key generation. The integration generates its keypair once, at first-run setup. The keypair can be per-deployment (one keypair for all elections that deployment creates) or per-election (a new keypair each time). Per-deployment is simpler and is what we recommend for most integrations.
- Private-key storage. The private key lives on the integrationās host only ā
.env, a secrets manager, KMS, etc. It must not be checked into the integrationās source repository: anyone forking the repo would otherwise gain owner-equivalent access to every election created by every deployment that uses that forkās default key. - Public-key transmission. The public key is sent to BetterVoting once per election, in the
Election.auth_keyfield of the create payload. - Signing management requests. The integration mints a JWT signed with its private key, sets it as the
custom_id_tokencookie, and calls the management endpoint. Keep JWT lifetimes short (minutes, not days) ā there is no revocation mechanism beyond rotating the electionāsauth_key.
The verified JWTās claims populate req.user. Useful claim conventions:
| Claim | Used by |
|---|---|
sub | The user ID associated with the request. |
email | Treated as a verified email by downstream code that reads req.user.email. Set this only if your integration has actually verified the email. |
Worked example (Python)
import jwt, requests, time
PRIVATE_KEY = open('integration_private.pem').read() # PKCS#8 PEM
PUBLIC_KEY = open('integration_public.pem').read() # SPKI PEM
# Create the election
payload = {
"Election": {
"title": "Demo poll",
"state": "open",
"races": [...],
"settings": {...},
"auth_key": PUBLIC_KEY,
},
}
create_resp = requests.post(
"https://bettervoting.com/API/Elections",
json=payload,
)
election = create_resp.json()["election"]
election_id = election["election_id"]
# Later: delete the election when the discord poll ends
mgmt_token = jwt.encode(
{
"sub": "discord-bot-instance-42",
"iat": int(time.time()),
"exp": int(time.time()) + 60, # 1-minute lifetime
},
PRIVATE_KEY,
algorithm="RS256",
)
requests.delete(
f"https://bettervoting.com/API/Election/{election_id}",
cookies={"custom_id_token": mgmt_token},
)
Threat model ā what auth_key protects against, and what it doesnāt
auth_key is a narrow mechanism. It protects exactly one thing: only the holder of the integrationās private key can perform owner-equivalent operations on an election that was created with the matching public key.
What it does protect against
- A different operator running the same integration code. Each operator holds their own private key. They can only manage their own elections.
- A fork of the integrationās repo. Forks ship without a default keypair, so they cannot reach back and impersonate the original integrationās elections.
- Frontend
owner_idspoofing. The cryptographic check onauth_keybypasses the value ofowner_idfor elections that opt into this mechanism. (Note that the underlyingowner_id-trust issue elsewhere in the system is tracked separately;auth_keydoesnāt fix it for elections that donāt useauth_key.) - Future BetterVoting DB read. Since BV stores only the public key, a DB exfiltration does not yield signing material for integration-managed elections.
What it does not protect against
- Voters in an integration-managed election.
auth_keyis owner-side only. Voter authentication is governed by the electionāsvoter_authenticationsettings (see Voter Authentication Modes), not byauth_key. - A compromised integration host. If the integrationās private key leaks (host break-in, accidental commit, leaked backup), the attacker can manage every election created with the matching public key. Rotation requires setting a new
auth_keyon each affected election individually. - A malicious integration acting on its own elections. By design, the integration is owner-equivalent for its own elections. Nothing here prevents the integration from, e.g., deleting the elections it created.
- Revocation of issued JWTs. There is no jti / blocklist. Use short expirations (
exp) and rotateauth_keyif you suspect a key compromise. - Replay across elections. The JWT itself does not bind to an election ID; if a token signed by key K is intercepted, it can be replayed against any election whose
auth_keyis the matching public key for K. Mitigate with shortexpvalues; consider per-election keypairs if your threat model requires hard isolation.
Operational rules for integrations
If you are writing or reviewing an external integration that uses auth_key, the following must hold:
- No default keypair in the repo. First-run setup generates or requires an operator-supplied private key. CI tests may use ephemeral keys, but the production code path must never fall back to a baked-in key.
- Private key in a secret store, not in the codebase or in logs.
.envis the floor; secrets managers / KMS are preferred for anything that serves real users. - Short-lived JWTs.
expshould be on the order of seconds to a few minutes. There is no revocation; expiry is the only safety net. - Document the key-rotation procedure. If your private key is compromised, you must be able to re-issue and push a new
auth_keyto every election youāve created that still matters. Plan this before you need it. Note that as soon as you push a new public auth_key to an election, all future edits to the election will need a JWT signed by the new corresponding new private key. - Donāt expose
auth_keyto end users. It is an integration-internal concept. End users of your integration should never see the value, and should not be able to set it.
Backend implementation pointers
- Field on
Election:packages/shared/src/domain_model/Election.ts - Write-time validation (RS256 PEM-public-key requirement):
electionValidationin the same file. - Read-side stripping:
removeHiddenFieldsin the same file. Called fromreturnElection(Controllers/Election/elections.controllers.ts) only when the requester is not an owner or admin of the election. Owner- authenticated reads retainauth_keyso integrations can do read-modify- write edits and verify the stored key. Listing endpoints (getElectionsController.ts) always strip ā fetch the individual election if you need the key. - Verifier:
AccountServiceUtils.extractUserFromRequestinpackages/backend/src/Services/Account/AccountServiceUtils.tsā RS256-locked, PEM-public-key-only. - Middleware:
electionSpecificAuthinpackages/backend/src/Controllers/Election/elections.controllers.tsā wired into/API/Elections/:id,/API/Ballots/:id, and/API/Roll/:idparam hooks. - End-to-end test:
packages/backend/src/test/customAuthKey.test.ts.
Future work
Things that are deliberately not in scope today but may be worth doing if this surface grows:
- JWKS URLs. Hosting a JWKS endpoint per integration would let integrations rotate keys without re-issuing
auth_keyon every election. Today, key rotation is per-election. - Per-election binding in JWT claims. Requiring
audto match the election ID would close the replay-across-elections gap noted above without forcing per-election keypairs.