Edit Page

API Key Authentication

RESTHeart Cloud

API 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

enabled

true to enable the mechanism

false

authenticator

the Authenticator that verifies the key, typically mongoApiKeyAuthenticator

none

prefix

the string every key must start with, e.g. rhak_

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

enabled

true to enable the authenticator

false

keys-db

the database holding the keys collection

none

keys-collection

the collection holding key documents

none

prop-hash

the field holding the key’s SHA-256 hash

hash

prop-principal

the field used as account name and _id

user

prop-roles

the field holding the account’s roles

roles

prop-expires

the field holding the expiry date

expiresAt

track-last-used

update lastUsedAt on every successful verification

true

cache-enabled

cache verified keys to avoid a DB round trip per request

true

cache-size, cache-ttl, cache-expire-policy

standard cache tuning; cache-ttl also bounds how fast a revocation takes effect (see below)

1000, 60000, AFTER_WRITE

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 of prop-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 expiresAt and 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). Set cache-enabled: false where revocation must be instantaneous.

  • Usage tracking: with track-last-used, each successful verification updates lastUsedAt — 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.