Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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.crypto.password;

/**
* A {@link PasswordEncoder} that appends a server-side pepper to the raw password before
* delegating to another {@link PasswordEncoder}.
*
* <p>
* A pepper is a shared secret that should be stored separately from the encoded password,
* for example in an environment variable or secret manager. It is not included in the
* encoded password and must be available when matching passwords.
* </p>
*
* <p>
* This encoder does not replace the per-password salt generated by the delegate. It adds
* a second secret input before the delegate performs its own password encoding.
* </p>
*
* <pre>
* PasswordEncoder delegate = PasswordEncoderFactories.createDelegatingPasswordEncoder();
* PasswordEncoder passwordEncoder = new PepperPasswordEncoder(delegate, pepper);
* </pre>
*
* @author KoreaNirsa
* @since 7.1
*/
public final class PepperPasswordEncoder extends AbstractValidatingPasswordEncoder {

private final PasswordEncoder passwordEncoder;

private final String pepper;

/**
* Creates a new instance.
* @param passwordEncoder the {@link PasswordEncoder} to delegate to
* @param pepper the server-side secret to combine with the raw password
*/
public PepperPasswordEncoder(PasswordEncoder passwordEncoder, CharSequence pepper) {
if (passwordEncoder == null) {
throw new IllegalArgumentException("passwordEncoder cannot be null");
}
if (pepper == null) {
throw new IllegalArgumentException("pepper cannot be null");
}
if (pepper.length() == 0) {
throw new IllegalArgumentException("pepper cannot be empty");
}
this.passwordEncoder = passwordEncoder;
this.pepper = pepper.toString();
}

@Override
protected String encodeNonNullPassword(String rawPassword) {
return this.passwordEncoder.encode(appendPepper(rawPassword));
}

@Override
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
return this.passwordEncoder.matches(appendPepper(rawPassword), encodedPassword);
}

@Override
protected boolean upgradeEncodingNonNull(String encodedPassword) {
return this.passwordEncoder.upgradeEncoding(encodedPassword);
}

private String appendPepper(String rawPassword) {
return rawPassword + this.pepper;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* 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.crypto.password;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;

/**
* Tests for {@link PepperPasswordEncoder}.
*
* @author KoreaNirsa
*/
@ExtendWith(MockitoExtension.class)
class PepperPasswordEncoderTests extends AbstractPasswordEncoderValidationTests {

private static final String PEPPER = "server-side-secret";

private static final String OTHER_PEPPER = "other-server-side-secret";

private static final String RAW_PASSWORD = "password";

private static final String PEPPERED_PASSWORD = RAW_PASSWORD + PEPPER;

private static final String ENCODED_PASSWORD = "encoded-password";

@Mock
private PasswordEncoder delegate;

@BeforeEach
void setup() {
setEncoder(new PepperPasswordEncoder(this.delegate, PEPPER));
}

@Test
void constructorWhenPasswordEncoderNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new PepperPasswordEncoder(null, PEPPER))
.withMessage("passwordEncoder cannot be null");
}

@Test
void constructorWhenPepperNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new PepperPasswordEncoder(this.delegate, null))
.withMessage("pepper cannot be null");
}

@Test
void constructorWhenPepperEmptyThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new PepperPasswordEncoder(this.delegate, ""))
.withMessage("pepper cannot be empty");
}

@Test
void encodeWhenValidThenDelegatesPepperedPassword() {
given(this.delegate.encode(PEPPERED_PASSWORD)).willReturn(ENCODED_PASSWORD);

assertThat(getEncoder().encode(RAW_PASSWORD)).isEqualTo(ENCODED_PASSWORD);

verify(this.delegate).encode(PEPPERED_PASSWORD);
}

@Test
void matchesWhenValidThenDelegatesPepperedPassword() {
given(this.delegate.matches(PEPPERED_PASSWORD, ENCODED_PASSWORD)).willReturn(true);

assertThat(getEncoder().matches(RAW_PASSWORD, ENCODED_PASSWORD)).isTrue();

verify(this.delegate).matches(PEPPERED_PASSWORD, ENCODED_PASSWORD);
}

@Test
void matchesWhenDelegateDoesNotMatchThenFalse() {
given(this.delegate.matches(PEPPERED_PASSWORD, ENCODED_PASSWORD)).willReturn(false);

assertThat(getEncoder().matches(RAW_PASSWORD, ENCODED_PASSWORD)).isFalse();

verify(this.delegate).matches(PEPPERED_PASSWORD, ENCODED_PASSWORD);
}

@Test
void matchesWhenPepperDifferentThenFalse() {
PasswordEncoder delegate = NoOpPasswordEncoder.getInstance();
PasswordEncoder encoder = new PepperPasswordEncoder(delegate, PEPPER);
PasswordEncoder otherEncoder = new PepperPasswordEncoder(delegate, OTHER_PEPPER);
String encodedPassword = encoder.encode(RAW_PASSWORD);

assertThat(encoder.matches(RAW_PASSWORD, encodedPassword)).isTrue();
assertThat(otherEncoder.matches(RAW_PASSWORD, encodedPassword)).isFalse();
}

@Test
void upgradeEncodingWhenValidThenDelegatesEncodedPassword() {
given(this.delegate.upgradeEncoding(ENCODED_PASSWORD)).willReturn(true);

assertThat(getEncoder().upgradeEncoding(ENCODED_PASSWORD)).isTrue();

verify(this.delegate).upgradeEncoding(ENCODED_PASSWORD);
verifyNoMoreInteractions(this.delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,27 @@ This is important, because unlike encryption, password hashes are designed so th
Since there is no way to recover the plaintext, it is difficult to migrate the passwords.
While it is simple for users to migrate `NoOpPasswordEncoder`, we chose to include it by default to make it simple for the getting-started experience.

[[authentication-password-storage-pepper]]
=== PepperPasswordEncoder

`PepperPasswordEncoder` wraps another `PasswordEncoder` and adds a server-side secret, also known as a pepper, before delegating password encoding and matching.
Unlike a salt, a pepper is not stored with the encoded password.
Store the pepper separately from the password database, for example in an environment variable or secret manager.

`PepperPasswordEncoder` does not replace the per-password salt generated by the delegate.
Instead, it appends the pepper to the raw password and delegates the combined value to the configured `PasswordEncoder`.
This keeps the delegate responsible for adaptive password hashing and password storage format decisions.

[WARNING]
====
Changing or losing the pepper prevents existing passwords from matching.
Plan pepper storage, backup, and rotation before using a pepper in production.
If the delegate has input-length limits, those limits apply to the raw password and pepper combined.
====

.PepperPasswordEncoder
include-code::./PepperPasswordEncoderUsage[tag=pepperPasswordEncoder,indent=0]

[[authentication-password-storage-dep-getting-started]]
=== Getting Started Experience

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.springframework.security.docs.features.authentication.authenticationpasswordstoragepepper;

import static org.junit.Assert.assertTrue;

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.PepperPasswordEncoder;

public class PepperPasswordEncoderUsage {
public void testPepperPasswordEncoder() {
// tag::pepperPasswordEncoder[]
String pepper = getPepperFromSecretManager();
PasswordEncoder delegate = PasswordEncoderFactories.createDelegatingPasswordEncoder();
PasswordEncoder encoder = new PepperPasswordEncoder(delegate, pepper);

String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
// end::pepperPasswordEncoder[]
}

private String getPepperFromSecretManager() {
return "secret-pepper";
}
}