API Key Authentication
RESTHeart CloudAPI Key Authentication
|
Note
|
Available from RESTHeart v9.8. |
An API key is a long-lived, revocable credential for callers that are not a browser — a CLI, a CI job, a script, a partner integration — and it’s the right choice when identity is federated: an OAuth user has no password, so there is nothing for Basic to carry and nothing for a token endpoint to exchange.
Once configured (see below), a key is sent as a standard Authorization: Bearer header (RFC 6750):
curl -H "Authorization: Bearer rhak_3f8a...c0e1" https://localhost:8443/db/coll
Two plugins are involved:
-
apiKeyAuthMechanism— reads the header and extracts the key. -
mongoApiKeyAuthenticator— verifies the key against a MongoDB collection and builds the account.
Both ship with RESTHeart but are disabled by default.
Configuration
apiKeyAuthMechanism
apiKeyAuthMechanism:
enabled: true
authenticator: mongoApiKeyAuthenticator
prefix: rhak_
| param | description | default |
|---|---|---|
|
|
|
|
the Authenticator that verifies the key, typically |
none |
|
the string every key must start with, e.g. |
none |
|
Important
|
Pick a distinctive prefix and keep it. It tells a key apart from a JWT on the same Bearer header (see Sharing the Bearer scheme with JWT below), and it’s what lets secret scanners spot a key that has leaked into a repo or a log.
|
mongoApiKeyAuthenticator
mongoApiKeyAuthenticator:
enabled: true
keys-db: restheart
keys-collection: apiKeys
prop-hash: hash
prop-principal: user
prop-roles: roles
prop-expires: expiresAt
track-last-used: true
cache-enabled: true
cache-size: 1000
cache-ttl: 60000
cache-expire-policy: AFTER_WRITE
| param | description | default |
|---|---|---|
|
|
|
|
the database holding the keys collection |
none |
|
the collection holding key documents |
none |
|
the field holding the key’s SHA-256 hash |
|
|
the field used as account name and |
|
|
the field holding the account’s roles |
|
|
the field holding the expiry date |
|
|
update |
|
|
cache verified keys to avoid a DB round trip per request |
|
|
standard cache tuning; |
|
A key document looks like this. The key itself is never stored — only its hash:
{
"_id": "6f2c...",
"user": "andrea@example.com",
"roles": [ "cli" ],
"hash": "cff1ff8ac100e099a885d9079ebe17919408763bca38289196d38490f335ff42",
"expiresAt": { "$date": "2026-11-21T00:00:00Z" },
"lastUsedAt": { "$date": "2026-08-24T09:56:48Z" }
}
How the account is built
-
Roles come from the key, not the user. The account carries the roles named on the key document. No roles on the key → an account with no roles, no fallback to the user’s own roles. This makes a key deny-by-default: it can be narrower than the person or system holding it.
-
Identity comes from
prop-principal, and travels as_id. The account’s name and its_id(the field permissions and queries actually read) are both set to the value ofprop-principal. So code written for password-authenticated users works unchanged for a key:equals(@user._id, ${userId}) # an ACL permission { "$arg": ["@user._id", null] } # a GraphQL mapping -
Nothing else in the key document is exposed to ACL predicates — it’s yours to shape freely.
Hashing, expiry and revocation
-
Hashing: SHA-256, not bcrypt. A key is high-entropy random data, not a human-chosen secret, so brute force isn’t the threat — and bcrypt’s cost would be paid on every request, not once per login.
-
Shown once: since only the hash is stored, the plaintext key is not recoverable after issuance. A lost key is revoked and reissued.
-
Expiry: set
expiresAtand the key stops working after that date. Checked at verification time, independent of any TTL index — a TTL index reclaims lazily, so a just-expired key can still be present in the collection. -
Revocation: delete the key’s document. Takes effect within
cache-ttl(verified keys are cached). Setcache-enabled: falsewhere revocation must be instantaneous. -
Usage tracking: with
track-last-used, each successful verification updateslastUsedAt— useful for spotting the key nobody remembers issuing.
Where keys live
keys-db and keys-collection are configurable — keys can live wherever suits the deployment, including alongside the users themselves. A dedicated collection is the default because a MongoDB TTL index only acts on a top-level date field (expiresAt inside a user document wouldn’t auto-expire), and because lastUsedAt writes would otherwise touch the user document, and its cache, on every authenticated request.
Issuing a key
RESTHeart verifies API keys; it does not issue them. Whether that needs a plugin depends on who is allowed to create one:
|
Note
|
Running on RESTHeart Cloud? Its Personal Access Tokens (docs) already come with built-in issuance, listing and revocation — none of the below applies. This section is about the self-hosted, open-source mongoApiKeyAuthenticator.
|
From a trusted script — no plugin needed
A key document is an ordinary document in a collection, so writing one is a normal RESTHeart REST API call. No plugin is needed as long as whoever runs the script already has write access to the apiKeys collection — an operator, or a CI job with its own credentials:
# 1. generate a high-entropy key with the configured prefix
KEY="rhak_$(openssl rand -hex 32)"
# 2. hash it — the plaintext must never reach the server
HASH=$(printf '%s' "$KEY" | shasum -a 256 | cut -d' ' -f1)
# 3. store the hash as an ordinary document, via RESTHeart's own REST API
# (wm=upsert creates the document since it doesn't exist yet)
curl -s -u admin:secret -X PUT \
"http://localhost:8080/apiKeys/$(uuidgen)?wm=upsert" \
-H 'Content-Type: application/json' \
-d "{\"user\":\"andrea@example.com\",\"roles\":[\"cli\"],\"hash\":\"$HASH\",\"expiresAt\":{\"\$date\":\"2027-12-31T00:00:00Z\"}}"
# 4. hand the key to the caller now — it's not recoverable afterwards
echo "$KEY"
This is exactly how RESTHeart’s own test suite seeds keys (core/src/test/java/karate/api-key-auth-setup.feature): generate, hash, PUT …/apiKeys/<id>?wm=upsert with the hash — nothing else running server-side.
From a self-service endpoint — needs a plugin
When callers themselves should be able to request a key — rather than an operator running the script above — don’t give them direct write access to apiKeys: an unprivileged caller could then set roles to anything it likes. Wrap the same three steps in a Service that runs with its own privileges and decides what the caller is allowed to have:
@RegisterPlugin(name = "issue-api-key", description = "issues an API key for the caller")
public class IssueApiKeyService implements JsonService {
@Inject("mclient")
private MongoClient mclient;
@Override
public void handle(JsonRequest req, JsonResponse res) throws Exception {
if (req.getMethod() != METHOD.POST) {
res.setStatusCode(HttpStatus.SC_METHOD_NOT_ALLOWED);
return;
}
// 1. generate a high-entropy secret and prefix it
var secret = new byte[32];
new SecureRandom().nextBytes(secret);
var key = "rhak_" + Base64.getUrlEncoder().withoutPadding().encodeToString(secret);
// 2. hash it — only the hash is ever stored
var digest = MessageDigest.getInstance("SHA-256").digest(key.getBytes(UTF_8));
var hash = HexFormat.of().formatHex(digest);
// 3. store the key document (roles/expiry are up to your own policy)
var doc = new Document()
.append("user", req.getAuthenticatedAccount().getPrincipal().getName())
.append("roles", List.of("cli"))
.append("hash", hash)
.append("expiresAt", Date.from(Instant.now().plus(90, ChronoUnit.DAYS)));
mclient.getDatabase("restheart").getCollection("apiKeys").insertOne(doc);
// 4. return the plaintext once — the caller must save it now
res.setContent(object().put("apiKey", key));
res.setStatusCode(HttpStatus.SC_CREATED);
}
}
For a config-only shortcut on simple cases, the ACL variable @rnd(bits) (see permissions) can generate a random value directly inside a permission. It doesn’t by itself give you "store the hash, show the plaintext once" — for that pairing you still need a plugin like the one above.
Sharing the Bearer scheme with JWT
jwtAuthenticationMechanism also reads Authorization: Bearer. The two mechanisms coexist because the prefix decides: a Bearer value that doesn’t start with your configured prefix gets NOT_ATTEMPTED from apiKeyAuthMechanism and falls through to JWT handling unchanged. Once the prefix matches, apiKeyAuthMechanism owns the outcome — an unknown, revoked or expired key is NOT_AUTHENTICATED, not a fall-through, since a prefixed key is unambiguous. Plugin priority guarantees apiKeyAuthMechanism sees the request first, so enabling it never changes the outcome for requests that don’t carry a prefixed key.