Programmatic Access — API keys for non-browser clients#

palmyra-dbpwd-mgmt covers username/password for people. Python scripts, batch jobs, and headless agents need a different credential: an API key that can be provisioned per client, revoked independently, and audited. This guide shows how to add that as a second AuthenticationProvider alongside your user chain so both land on the same ACL model.

The design in one diagram#

┌──────────────────────┐
│  Client — browser    │ ─── POST /auth/login (user/pass) ─► session cookie
│                      │ ─── subsequent calls with JSESSIONID
└──────────────────────┘                    │
                                            │  both resolve to
┌──────────────────────┐                    │  xpm_user.login_name
│  Client — script     │                    │  → same @Permission evaluator
│  (Python/Java/curl)  │ ── X-Api-Key: ... ─┤
└──────────────────────┘                    │
                                            ▼
                                    handler with @Permission("X", ...)
                                    → PalmyraPermissionEvaluator
                                    → xpm_acl_group_permission row

One principal model, two ways to authenticate.

1. Key storage table#

CREATE TABLE app_api_key (
    id              BIGINT       PRIMARY KEY AUTO_INCREMENT,
    serial_number   VARCHAR(64)  NOT NULL UNIQUE,   -- row locator (client sends in header)
    token           VARCHAR(128) NOT NULL,          -- hash (salted MD5) of the secret
    salt            VARCHAR(128),                   -- per-row random; NULL = legacy plaintext
    user_id         INT,                            -- FK xpm_user.id — for ACL inheritance
    client_type     VARCHAR(16)  NOT NULL,          -- 'AGENT', 'SCRIPT', ...
    revoked         SMALLINT     NOT NULL DEFAULT 0,
    expires_on      DATETIME,
    last_used_on    DATETIME,
    created_by      VARCHAR(50)  NOT NULL,
    last_upd_by     VARCHAR(50)  NOT NULL,
    created_on      DATETIME     NOT NULL,
    last_upd_on     DATETIME     NOT NULL
);

Hash scheme — same as palmyra-dbpwd-mgmt’s user password:

token = MD5_hex(salt + secret)

where secret is a UUID string generated at key-issue time and handed to the client once. The client stores the serial_number + secret pair; the server stores serial_number + salt + token.

2. Authentication provider#

@Component
@RequiredArgsConstructor
public class ApiKeyAuthenticationProvider implements AuthenticationProvider {

    public static final String HEADER_SERIAL = "X-Api-Key-Serial";
    public static final String HEADER_TOKEN  = "X-Api-Key-Token";
    public static final String ROLE_AGENT  = "ROLE_AGENT";
    public static final String ROLE_SCRIPT = "ROLE_SCRIPT";

    private final ApiKeyJpaRepo keyRepo;
    private final JdbcTemplate  jdbc;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (!(authentication instanceof ApiKeyAuthenticationToken t)) return null;

        ApiKeyEntity key = keyRepo.findBySerialNumber(t.getSerial()).orElseThrow(
                () -> new BadCredentialsException("Unknown API key"));

        if (key.getRevoked() != 0) throw new DisabledException("API key revoked");
        if (key.getExpiresOn() != null && key.getExpiresOn().isBefore(LocalDateTime.now()))
            throw new DisabledException("API key expired");

        if (!matches(key, t.getSecret()))
            throw new BadCredentialsException("Invalid API key");

        touchLastUsed(key);

        // Principal = the user the key is bound to, so @Permission checks inherit grants.
        String principal = resolveLogin(key);
        List<GrantedAuthority> auths = List.of(new SimpleGrantedAuthority(roleFor(key.getClientType())));
        return new UsernamePasswordAuthenticationToken(principal, "[apikey]", auths);
    }

    @Override public boolean supports(Class<?> a) { return ApiKeyAuthenticationToken.class.isAssignableFrom(a); }

    private static boolean matches(ApiKeyEntity k, String secret) {
        if (k.getSalt() == null) return constantTimeEquals(secret, k.getToken()); // legacy plaintext
        String h = DigestUtils.md5DigestAsHex((k.getSalt() + secret).getBytes(StandardCharsets.UTF_8));
        return constantTimeEquals(h, k.getToken());
    }

    private String resolveLogin(ApiKeyEntity k) {
        if (k.getUserId() == null) return k.getSerialNumber();
        return Optional.ofNullable(jdbc.queryForObject(
                "SELECT login_name FROM xpm_user WHERE id = ?", String.class, k.getUserId()))
                .orElse(k.getSerialNumber());
    }

    private void touchLastUsed(ApiKeyEntity k) {
        try { k.setLastUsedOn(LocalDateTime.now()); keyRepo.save(k); }
        catch (RuntimeException ignored) { /* non-fatal */ }
    }

    private static String roleFor(String t) {
        return "AGENT".equalsIgnoreCase(t)  ? ROLE_AGENT
             : "SCRIPT".equalsIgnoreCase(t) ? ROLE_SCRIPT
             : "ROLE_API_KEY";
    }

    private static boolean constantTimeEquals(String a, String b) {
        if (a == null || b == null || a.length() != b.length()) return false;
        int diff = 0;
        for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
        return diff == 0;
    }
}

3. Filter to turn headers into a token#

@RequiredArgsConstructor
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {

    private final AuthenticationManager authManager;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String serial = req.getHeader(ApiKeyAuthenticationProvider.HEADER_SERIAL);
        String secret = req.getHeader(ApiKeyAuthenticationProvider.HEADER_TOKEN);

        if (serial != null && secret != null
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            try {
                Authentication result = authManager.authenticate(
                        new ApiKeyAuthenticationToken(serial, secret));
                if (result != null && result.isAuthenticated())
                    SecurityContextHolder.getContext().setAuthentication(result);
            } catch (AuthenticationException ignored) { /* fall through */ }
        }
        chain.doFilter(req, res);
    }
}

4. Wire both chains#

Add the filter to your API chain before UsernamePasswordAuthenticationFilter — if the API-key headers are present, that filter short-circuits; otherwise the chain falls through to session-based auth:

@Bean
@Order(Ordered.LOWEST_PRECEDENCE)
public SecurityFilterChain apiChain(HttpSecurity http,
                                    SecurityContextRepository repo) throws Exception {
    RequestMatcher apiKeyReq = r -> r.getHeader(ApiKeyAuthenticationProvider.HEADER_SERIAL) != null;

    return http
            .securityMatcher("/**")
            .authenticationProvider(localDbProvider)
            .authenticationProvider(apiKeyProvider)
            .addFilterBefore(new ApiKeyAuthenticationFilter(authManager()),
                             UsernamePasswordAuthenticationFilter.class)
            .securityContext(c -> c.securityContextRepository(repo))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
            .csrf(c -> c
                    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                    .ignoringRequestMatchers("/auth/login", "/auth/logout")
                    .ignoringRequestMatchers(apiKeyReq))           // stateless = no CSRF
            .authorizeHttpRequests(a -> a
                    .requestMatchers("/auth/login", "/auth/logout").permitAll()
                    .anyRequest().authenticated())
            .exceptionHandling(e -> e.authenticationEntryPoint(
                    new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
            .build();
}

5. Admin endpoints to mint / list / revoke#

Mint returns the secret once — store the hash, give the plaintext to the caller, they save it:

@RestController
@RequiredArgsConstructor
@RequestMapping("/admin/apikey")
public class ApiKeyAdminController {
    private final ApiKeyJpaRepo repo;

    @PostMapping
    @PreAuthorize("hasPermission('XpmUser', 'CUD')")
    public ResponseEntity<MintResponse> mint(@RequestBody MintRequest req) {
        String secret = UUID.randomUUID().toString();
        String salt   = randomString(32);
        String hash   = DigestUtils.md5DigestAsHex((salt + secret).getBytes(UTF_8));

        ApiKeyEntity k = new ApiKeyEntity();
        k.setSerialNumber(req.getSerial());
        k.setSalt(salt);
        k.setToken(hash);
        k.setClientType(req.getClientType());
        k.setUserId(req.getUserId());
        k.setRevoked((short) 0);
        // audit fields …
        repo.save(k);

        return ResponseEntity.ok(new MintResponse(req.getSerial(), secret));   // secret shown once
    }

    @PostMapping("/{id}/revoke")
    @PreAuthorize("hasPermission('XpmUser', 'CUD')")
    public ResponseEntity<Void> revoke(@PathVariable long id) {
        ApiKeyEntity k = repo.findById(id).orElseThrow();
        k.setRevoked((short) 1);
        repo.save(k);
        return ResponseEntity.noContent().build();
    }
}

Python client — minimal#

import os, requests

SERIAL = os.environ["APP_API_KEY_SERIAL"]
SECRET = os.environ["APP_API_KEY_SECRET"]
BASE   = "https://app.example.com/api"

headers = {
    "X-Api-Key-Serial": SERIAL,
    "X-Api-Key-Token":  SECRET,
}

r = requests.get(f"{BASE}/palmyra/patient", headers=headers, params={"limit": 10})
r.raise_for_status()
print(r.json()["result"])

No session, no CSRF, no cookies. Every call carries its own authentication.

Operational notes#

  • Rotate on revoke — revoked keys stay in the table for audit; mint a new one with a distinct serial.
  • Bind to a user, not a role — the user_id column makes every key inherit its owner’s ACL grants via xpm_acl_group_permission. Grant-level changes apply to the key too.
  • Audit via last_used_on — update on every successful authenticate; a stale last_used_on helps you find unused keys to retire.
  • Expiry policy — decide per-client. Agents running on infrastructure: 1 year. Ad-hoc data-science scripts: 7–30 days.

See also#