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 rowOne 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_idcolumn makes every key inherit its owner’s ACL grants viaxpm_acl_group_permission. Grant-level changes apply to the key too. - Audit via
last_used_on— update on every successful authenticate; a stalelast_used_onhelps 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#
- Security Configuration Recipes — recipe #2 is the multi-chain setup this page depends on
- ACL Management integration guide — the
xpm_user/ grant model that both chains share