authenticationFailureHandler(final AuthenticationFailureHandler failureHandler) {
+ this.failureHandler = failureHandler;
+ return this;
+ }
+
+ @Override
+ public void init(final H builder) throws Exception {
+ super.init(builder);
+ final ApiKeySearchService searchService = getSearchService();
+ final ApiKeyDigest digest = getDigest();
+ final ApiKeyAuthenticationProvider authnProvider = new ApiKeyAuthenticationProvider(
+ searchService,
+ digest,
+ this.clock,
+ this.grantedAuthorityConverter
+ );
+ builder.authenticationProvider(authnProvider);
+ }
+
+ private ApiKeySearchService getSearchService() {
+ if (this.searchService != null) {
+ return this.searchService;
+ }
+
+ final ApiKeySearchService bean = this.context.getBean(ApiKeySearchService.class);
+ if (bean == null) {
+ throw new IllegalStateException("API key search service required");
+ }
+
+ return bean;
+ }
+
+ private ApiKeyDigest getDigest() {
+ if (this.digest != null) {
+ return this.digest;
+ }
+
+ final ApiKeyDigest bean = this.context.getBean(ApiKeyDigest.class);
+ if (bean == null) {
+ throw new IllegalStateException("API key digest required");
+ }
+
+ return bean;
+ }
+
+ @Override
+ public void configure(final H http) {
+ final AuthenticationManager authnManager = http.getSharedObject(AuthenticationManager.class);
+ final SecurityContextHolderStrategy securityContextHolderStrategy = getSecurityContextHolderStrategy();
+ final ApiKeyAuthenticationFilter filter = new ApiKeyAuthenticationFilter(
+ authnManager,
+ this.authnConverter,
+ securityContextHolderStrategy,
+ this.securityContextRepository,
+ this.successHandler,
+ this.failureHandler
+ );
+ http.addFilter(postProcess(filter));
+ }
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java
new file mode 100644
index 00000000000..d5f2f8ba08f
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKey.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.random.RandomGenerator;
+
+import org.springframework.util.Assert;
+
+/**
+ * API key that consists ID and secret parts.
+ *
+ * ID part allows efficiently searching API key information in some storage, such as
+ * relational database ({@link ApiKeySearchService} interface is used for this purpose).
+ * API key ID should not be used alone as it's prune to timing attacks (storages cannot
+ * use constant-time comparison because of efficiency requirements), so separate secret
+ * part is used.
+ *
+ * Secret part should be hashed before storing API key data just the same way as
+ * passwords, except that API keys do not require using specific slow hashing algorithms
+ * used for passwords (such BCrypt, Argon, etc.).
+ *
+ * @author Alexey Razinkov
+ */
+public final class ApiKey implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 5948279771096057355L;
+
+ public static final SecureRandom RND = new SecureRandom();
+
+ public static final int DEFAULT_ID_BYTES_LENGTH = 16;
+
+ public static final int DEFAULT_SECRET_BYTES_LENGTH = 16;
+
+ public static ApiKey random() {
+ return random(RND, DEFAULT_ID_BYTES_LENGTH, DEFAULT_SECRET_BYTES_LENGTH);
+ }
+
+ public static ApiKey random(final RandomGenerator random, final int idBytesLength, final int secretBytesLength) {
+ Objects.requireNonNull(random);
+ final byte[] idBytes = new byte[idBytesLength];
+ final byte[] secretBytes = new byte[secretBytesLength];
+ random.nextBytes(idBytes);
+ random.nextBytes(secretBytes);
+ return new ApiKey(idBytes, secretBytes);
+ }
+
+ public static ApiKey parse(final String value) {
+ return parse(value, DEFAULT_ENCODER, DEFAULT_DECODER);
+ }
+
+ public static ApiKey parse(final String value, final Function encoder,
+ final Function decoder) {
+ Assert.hasText(value, "API key must be provided");
+ Objects.requireNonNull(encoder);
+ Objects.requireNonNull(decoder);
+
+ final String[] parts = value.split("_", -1);
+ Assert.isTrue(parts.length == 2, "API key has invalid format");
+
+ final String apiKeyId = parts[0];
+ Assert.hasText(apiKeyId, "API key has invalid format");
+
+ final String apiKeySecret = parts[1];
+ Assert.hasText(apiKeySecret, "API key has invalid format");
+
+ return new ApiKey(apiKeyId, decoder.apply(apiKeySecret), encoder);
+ }
+
+ private final String id;
+
+ private final byte[] secret;
+
+ private final Function encoder;
+
+ private ApiKey(final byte[] id, final byte[] secret) {
+ this(DEFAULT_ENCODER.apply(id), secret, DEFAULT_ENCODER);
+ }
+
+ private ApiKey(final String id, final byte[] secret, final Function encoder) {
+ Assert.hasText(id, "API key ID cannot be empty");
+ Assert.isTrue(secret != null && secret.length> 0, "API key secret required");
+ Objects.requireNonNull(encoder);
+ this.id = id;
+ this.secret = Arrays.copyOf(secret, secret.length);
+ this.encoder = encoder;
+ }
+
+ public String getId() {
+ return this.id;
+ }
+
+ public byte[] getSecret() {
+ return Arrays.copyOf(this.secret, this.secret.length);
+ }
+
+ public String asToken() {
+ return this.id + '_' + this.encoder.apply(this.secret);
+ }
+
+ @Override
+ public String toString() {
+ return "DefaultApiKey{id='" + this.id + '}';
+ }
+
+ private static final Function DEFAULT_ENCODER = Base64.getEncoder()
+ .withoutPadding()::encodeToString;
+
+ private static final Function DEFAULT_DECODER = Base64.getDecoder()::decode;
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java
new file mode 100644
index 00000000000..fdd29f67103
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationException.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.time.Instant;
+
+import org.springframework.security.core.AuthenticationException;
+
+/**
+ * Base class for API key authentication exception.
+ *
+ * @author Alexey Razinkov
+ */
+public abstract sealed class ApiKeyAuthenticationException extends AuthenticationException {
+
+ private ApiKeyAuthenticationException() {
+ super("API key authentication failed");
+ }
+
+ private ApiKeyAuthenticationException(final Throwable t) {
+ super("API key authentication failed", t);
+ }
+
+ /**
+ * Thrown when failed to find stored API key with such ID.
+ */
+ public static final class NotFound extends ApiKeyAuthenticationException {
+
+ private final String apiKeyId;
+
+ public NotFound(final String apiKeyId) {
+ this.apiKeyId = apiKeyId;
+ }
+
+ public String getApiKeyId() {
+ return this.apiKeyId;
+ }
+
+ }
+
+ /**
+ * Thrown when API key is expired.
+ */
+ public static final class Expired extends ApiKeyAuthenticationException {
+
+ private final String apiKeyId;
+
+ private final Instant expiredAt;
+
+ private final Instant checkedAt;
+
+ public Expired(final String apiKeyId, final Instant expiredAt, final Instant checkedAt) {
+ this.apiKeyId = apiKeyId;
+ this.expiredAt = expiredAt;
+ this.checkedAt = checkedAt;
+ }
+
+ public String getApiKeyId() {
+ return this.apiKeyId;
+ }
+
+ public Instant getExpiredAt() {
+ return this.expiredAt;
+ }
+
+ public Instant getCheckedAt() {
+ return this.checkedAt;
+ }
+
+ }
+
+ /**
+ * Thrown when API key is expected as bearer token but no "Bearer" scheme found.
+ */
+ public static final class MissingBearerScheme extends ApiKeyAuthenticationException {
+
+ }
+
+ /**
+ * Thrown when API key is expected as bearer token and "Bearer" scheme is present but
+ * token is missing.
+ */
+ public static final class MissingBearerToken extends ApiKeyAuthenticationException {
+
+ }
+
+ /**
+ * Thrown when API key has invalid structure.
+ */
+ public static final class Invalid extends ApiKeyAuthenticationException {
+
+ private final String token;
+
+ public Invalid(final String token, final Throwable cause) {
+ super(cause);
+ this.token = token;
+ }
+
+ public String getToken() {
+ return this.token;
+ }
+
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java
new file mode 100644
index 00000000000..c23c7b889a7
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyAuthenticationProvider.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+
+/**
+ * API key authentication provider.
+ *
+ * @author Alexey Razinkov
+ */
+public final class ApiKeyAuthenticationProvider implements AuthenticationProvider {
+
+ private final ApiKeySearchService searchService;
+
+ private final ApiKeyDigest digest;
+
+ private final Clock clock;
+
+ private final Converter> grantedAuthorityConverter;
+
+ public ApiKeyAuthenticationProvider(final ApiKeySearchService searchService, final ApiKeyDigest digest,
+ final Clock clock, final Converter> grantedAuthorityConverter) {
+ this.searchService = Objects.requireNonNull(searchService);
+ this.digest = Objects.requireNonNull(digest);
+ this.clock = Objects.requireNonNull(clock);
+ this.grantedAuthorityConverter = Objects.requireNonNull(grantedAuthorityConverter);
+ }
+
+ @Override
+ public @Nullable Authentication authenticate(final Authentication authentication) throws AuthenticationException {
+ final ApiKeyToken apiKeyToken = (ApiKeyToken) authentication;
+ final ApiKey apiKey = apiKeyToken.getValue();
+
+ final StoredApiKey storedApiKey = this.searchService.findApiKeyHash(apiKey.getId());
+ if (storedApiKey == null) {
+ // mitigating timing attack by comparing secret against some dummy hash
+ final String dummy = this.digest.getDummyDigest();
+ this.digest.matches(apiKey.getSecret(), dummy);
+
+ throw new ApiKeyAuthenticationException.NotFound(apiKey.getId());
+ }
+
+ if (!this.digest.matches(apiKey.getSecret(), storedApiKey.secretHash())) {
+ throw new BadCredentialsException("API key secret does not match");
+ }
+
+ final Instant expiresAt = storedApiKey.expiresAt();
+ if (expiresAt != null) {
+ final Instant now = this.clock.instant();
+ if (expiresAt.isBefore(now)) {
+ throw new ApiKeyAuthenticationException.Expired(apiKey.getId(), expiresAt, now);
+ }
+ }
+
+ final Collection authorities = this.grantedAuthorityConverter.convert(storedApiKey);
+ return new AuthenticatedApiKeyToken(apiKey, authorities, apiKeyToken.getDetails());
+ }
+
+ @Override
+ public boolean supports(final Class> authentication) {
+ return ApiKeyToken.class.isAssignableFrom(authentication);
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java
new file mode 100644
index 00000000000..599e2c31289
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyDigest.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+/**
+ * Handles API key hashing and encoding into string as well as securely comparing hashes.
+ *
+ * @author Alexey Razinkov
+ * @see org.springframework.security.crypto.password.PasswordEncoder
+ */
+public interface ApiKeyDigest {
+
+ /**
+ * Hashes API key secret and encodes resulting byte array to string.
+ * @param apiKeySecret API key secret bytes
+ * @return Hash encoded into string
+ */
+ String digest(byte[] apiKeySecret);
+
+ /**
+ * Hashes provided API key secret and compares it against provided hash.
+ * @param apiKeySecret API key secret to hash
+ * @param digest Existing API key secret hash
+ * @return True if hash of provided API key secret matches existing hash
+ */
+ boolean matches(byte[] apiKeySecret, String digest);
+
+ /**
+ * @return Hash created of some dummy value for mitigating timing attack
+ */
+ String getDummyDigest();
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java
new file mode 100644
index 00000000000..0a183eca645
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySearchService.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Performs API key hash search in some storage (such as relational database).
+ *
+ * @author Alexey Razinkov
+ */
+@FunctionalInterface
+public interface ApiKeySearchService {
+
+ /**
+ * Searches for existing API key hash.
+ * @param apiKeyId API key ID
+ * @return Optional API key hash, never null
+ */
+ @Nullable StoredApiKey findApiKeyHash(String apiKeyId);
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java
new file mode 100644
index 00000000000..781349e4f17
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeySimpleGrantedAuthorityConverter.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+/**
+ * Converts API key claims to a collection of {@link SimpleGrantedAuthority}.
+ *
+ * @author Alexey Razinkov
+ */
+public class ApiKeySimpleGrantedAuthorityConverter implements Converter> {
+
+ @Override
+ public Collection convert(StoredApiKey source) {
+ final List result = new ArrayList();
+ for (final String claim : source.claims()) {
+ result.add(new SimpleGrantedAuthority(claim));
+ }
+ return result;
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java
new file mode 100644
index 00000000000..31238e2bf56
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/ApiKeyToken.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.util.Assert;
+
+/**
+ * Represents unauthenticated API token.
+ *
+ * @author Alexey Razinkov
+ */
+public final class ApiKeyToken extends AbstractAuthenticationToken {
+
+ private final ApiKey value;
+
+ private final Object details;
+
+ public ApiKeyToken(ApiKey value, Object details) {
+ super(null);
+ Assert.notNull(value, "API key must be provided");
+ this.value = value;
+ this.details = details;
+ }
+
+ public ApiKey getValue() {
+ return this.value;
+ }
+
+ @Override
+ public @NonNull byte[] getCredentials() {
+ return this.value.getSecret();
+ }
+
+ @Override
+ public @NonNull String getPrincipal() {
+ return this.value.getId();
+ }
+
+ @Override
+ public @Nullable Object getDetails() {
+ return this.details;
+ }
+
+ @Override
+ public void setDetails(@Nullable Object details) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void setAuthenticated(boolean authenticated) {
+ if (authenticated) {
+ throw new IllegalArgumentException();
+ }
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java b/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java
new file mode 100644
index 00000000000..1bc04becfb8
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/AuthenticatedApiKeyToken.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.util.Collection;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.util.Assert;
+
+/**
+ * Represents API key that successfully went through authentication process.
+ *
+ * @author Alexey Razinkov
+ */
+public final class AuthenticatedApiKeyToken extends AbstractAuthenticationToken {
+
+ private final ApiKey value;
+
+ /**
+ * Creates a token with the supplied array of authorities.
+ * @param value API key
+ * @param authorities the collection of GrantedAuthoritys for the principal
+ * represented by this authentication object.
+ */
+ public AuthenticatedApiKeyToken(ApiKey value, @Nullable Collection extends GrantedAuthority> authorities,
+ @Nullable Object details) {
+ super(authorities);
+ Assert.notNull(value, "API key must be provided");
+ this.value = value;
+ setAuthenticated(true);
+ setDetails(details);
+ }
+
+ public ApiKey getValue() {
+ return this.value;
+ }
+
+ @Override
+ public @NonNull byte[] getCredentials() {
+ return this.value.getSecret();
+ }
+
+ @Override
+ public @NonNull String getPrincipal() {
+ return this.value.getId();
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java b/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java
new file mode 100644
index 00000000000..c988bb0d3f0
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/Sha3ApiKeyDigest.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.Objects;
+import java.util.function.Function;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.util.Assert;
+
+/**
+ * {@link ApiKeyDigest} implementation via SHA3-256.
+ *
+ * @author Alexey Razinkov
+ */
+public class Sha3ApiKeyDigest implements ApiKeyDigest {
+
+ private static final Log log = LogFactory.getLog(Sha3ApiKeyDigest.class);
+
+ private static final String CODE = "{sha3_256}";
+
+ private final Function encoder;
+
+ private final Function decoder;
+
+ public Sha3ApiKeyDigest() {
+ this(Base64.getEncoder().withoutPadding()::encodeToString, Base64.getDecoder()::decode);
+ }
+
+ public Sha3ApiKeyDigest(Function encoder, Function decoder) {
+ this.encoder = Objects.requireNonNull(encoder);
+ this.decoder = Objects.requireNonNull(decoder);
+ }
+
+ @Override
+ public String digest(final byte[] apiKeySecret) {
+ Objects.requireNonNull(apiKeySecret);
+ final MessageDigest digest = createDigest();
+ final byte[] secretHashBytes = digest.digest(apiKeySecret);
+ final String secretHash = this.encoder.apply(secretHashBytes);
+ return CODE + secretHash;
+ }
+
+ @Override
+ public boolean matches(final byte[] apiKeySecret, final String hash) {
+ Objects.requireNonNull(apiKeySecret);
+ Objects.requireNonNull(hash);
+ Assert.isTrue(hash.startsWith(CODE), "Hash must start with " + CODE);
+
+ final MessageDigest digest = createDigest();
+ final byte[] actualSecretHash = digest.digest(apiKeySecret);
+ final String cleanHash = hash.substring(CODE.length());
+ final byte[] expectedSecretHash = this.decoder.apply(cleanHash);
+ return MessageDigest.isEqual(expectedSecretHash, actualSecretHash);
+ }
+
+ @Override
+ public String getDummyDigest() {
+ return this.encoder.apply(DummyHolder.VALUE);
+ }
+
+ private static MessageDigest createDigest() {
+ try {
+ return MessageDigest.getInstance("SHA3-256");
+ }
+ catch (final NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ /**
+ * Holds lazily initialized dummy hash value.
+ */
+ private static final class DummyHolder {
+
+ private static final byte[] VALUE;
+
+ static {
+ log.debug("Creating dummy hash for mitigating timing attack");
+ final MessageDigest digest = createDigest();
+ final byte[] deadbeef = new byte[] { 0xD, 0xE, 0xA, 0xD, 0xB, 0xE, 0xE, 0xF, };
+ VALUE = digest.digest(deadbeef);
+ }
+
+ }
+
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java b/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java
new file mode 100644
index 00000000000..3156fff4596
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/StoredApiKey.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.authentication.apikey;
+
+import java.time.Instant;
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.util.Assert;
+
+/**
+ * API key data stored somewhere (e.g., relational database).
+ *
+ * @author Alexey Razinkov
+ * @param id API key ID
+ * @param secretHash API key secret hash
+ * @param claims API key claim set, can be empty but never {@code null}
+ * @param expiresAt Optional expiration moment
+ */
+public record StoredApiKey(String id, String secretHash, Set claims, @Nullable Instant expiresAt) {
+
+ public StoredApiKey {
+ Assert.hasText(secretHash, "API key secret hash must be provided");
+ Assert.notNull(claims, "Claim set cannot be null");
+ }
+}
diff --git a/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java b/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java
new file mode 100644
index 00000000000..aacbb10c9d4
--- /dev/null
+++ b/core/src/main/java/org/springframework/security/authentication/apikey/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@NullMarked
+package org.springframework.security.authentication.apikey;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java
new file mode 100644
index 00000000000..8a6e46f6c00
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/ApiKeyAuthenticationFilter.java
@@ -0,0 +1,90 @@
+package org.springframework.security.web.authentication.apikey;
+
+import java.io.IOException;
+import java.util.Objects;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.log.LogMessage;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * @author Alexey Razinkov
+ */
+public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
+
+ private final AuthenticationManager authnManager;
+
+ private final AuthenticationConverter authnConverter;
+
+ private final SecurityContextHolderStrategy securityContextHolderStrategy;
+
+ private final SecurityContextRepository securityContextRepository;
+
+ @Nullable private final AuthenticationSuccessHandler successHandler;
+
+ @Nullable private final AuthenticationFailureHandler failureHandler;
+
+ public ApiKeyAuthenticationFilter(AuthenticationManager authnManager, AuthenticationConverter authnConverter,
+ SecurityContextHolderStrategy securityContextHolderStrategy,
+ SecurityContextRepository securityContextRepository, @Nullable AuthenticationSuccessHandler successHandler,
+ @Nullable AuthenticationFailureHandler failureHandler) {
+ this.authnManager = Objects.requireNonNull(authnManager);
+ this.securityContextHolderStrategy = Objects.requireNonNull(securityContextHolderStrategy);
+ this.authnConverter = Objects.requireNonNull(authnConverter);
+ this.securityContextRepository = Objects.requireNonNull(securityContextRepository);
+ this.successHandler = successHandler;
+ this.failureHandler = failureHandler;
+ }
+
+ @Override
+ protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response,
+ final FilterChain filterChain) throws ServletException, IOException {
+ try {
+ final Authentication authRequest = this.authnConverter.convert(request);
+ if (authRequest == null) {
+ this.logger.trace("Did not process authentication request since failed to find API key token");
+ filterChain.doFilter(request, response);
+ return;
+ }
+ final String apiKeyId = authRequest.getName();
+ this.logger.trace(LogMessage.format("Found API key '%s'", apiKeyId));
+ final Authentication authResult = this.authnManager.authenticate(authRequest);
+ final SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
+ context.setAuthentication(authResult);
+ this.securityContextHolderStrategy.setContext(context);
+ if (this.logger.isDebugEnabled()) {
+ this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
+ }
+ this.securityContextRepository.saveContext(context, request, response);
+ if (this.successHandler != null) {
+ this.successHandler.onAuthenticationSuccess(request, response, authRequest);
+ }
+ }
+ catch (final AuthenticationException ex) {
+ this.securityContextHolderStrategy.clearContext();
+ this.logger.debug("Failed to process authentication request", ex);
+ if (this.failureHandler != null) {
+ this.failureHandler.onAuthenticationFailure(request, response, ex);
+ }
+
+ return;
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+}
diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java
new file mode 100644
index 00000000000..4868c432285
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/BearerTokenAuthenticationConverter.java
@@ -0,0 +1,73 @@
+package org.springframework.security.web.authentication.apikey;
+
+import java.util.Base64;
+import java.util.Objects;
+import java.util.function.Function;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.security.authentication.AuthenticationDetailsSource;
+import org.springframework.security.authentication.apikey.ApiKey;
+import org.springframework.security.authentication.apikey.ApiKeyAuthenticationException;
+import org.springframework.security.authentication.apikey.ApiKeyToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.util.StringUtils;
+
+/**
+ * @author Alexey Razinkov
+ */
+public final class BearerTokenAuthenticationConverter implements AuthenticationConverter {
+
+ private final Function encoder;
+
+ private final Function decoder;
+
+ private final AuthenticationDetailsSource detailsSource;
+
+ public BearerTokenAuthenticationConverter() {
+ this(Base64.getEncoder()::encodeToString, Base64.getDecoder()::decode, new WebAuthenticationDetailsSource());
+ }
+
+ public BearerTokenAuthenticationConverter(Function encoder, Function decoder,
+ AuthenticationDetailsSource detailsSource) {
+ this.encoder = Objects.requireNonNull(encoder);
+ this.decoder = Objects.requireNonNull(decoder);
+ this.detailsSource = Objects.requireNonNull(detailsSource);
+ }
+
+ @Override
+ @Nullable public Authentication convert(final HttpServletRequest request) {
+ String headerValue = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (!StringUtils.hasText(headerValue)) {
+ return null;
+ }
+
+ headerValue = headerValue.stripLeading();
+ if (!headerValue.startsWith(SCHEME_PREFIX)) {
+ throw new ApiKeyAuthenticationException.MissingBearerScheme();
+ }
+
+ final String apiKeyToken = headerValue.substring(SCHEME_PREFIX.length());
+ if (!StringUtils.hasText(apiKeyToken)) {
+ throw new ApiKeyAuthenticationException.MissingBearerToken();
+ }
+
+ final ApiKey apiKey;
+ try {
+ apiKey = ApiKey.parse(apiKeyToken, encoder, decoder);
+ }
+ catch (final Exception ex) {
+ throw new ApiKeyAuthenticationException.Invalid(apiKeyToken, ex);
+ }
+
+ final Object details = this.detailsSource.buildDetails(request);
+ return new ApiKeyToken(apiKey, details);
+ }
+
+ private static final String SCHEME_PREFIX = "Bearer ";
+
+}
diff --git a/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java b/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java
new file mode 100644
index 00000000000..1b1891d2e4b
--- /dev/null
+++ b/web/src/main/java/org/springframework/security/web/authentication/apikey/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * API key authentication implementation package.
+ */
+@NullMarked
+package org.springframework.security.web.authentication.apikey;
+
+import org.jspecify.annotations.NullMarked;