Security · Backend
Signing In Without a Password
— A Vulnerability in All Five Languages
A security audit found that /auth/sign-in accepted a userId and nothing else — no password, no hash comparison, just an unconditional JWT. Here's how the same bug showed up five different ways, and the retry bug a brand-new 401 test uncovered along the way.
Some bugs are subtle. This one wasn't: POST /auth/sign-in took a userId, and issued a valid access token for it. No password field in some of the request bodies. No Credential store, no password hash, nothing to compare against. Anyone who knew — or guessed — another user's ID could sign in as them. Not a logic edge case; a full authentication bypass, and it existed identically in all five language implementations of this repo, because they'd all been ported from the same original gap.
Same Bug, Five Different Shapes
In Kotlin, the entire sign-in path was this short:
data class SignInRequest(@field:NotBlank val userId: String)
data class SignInResponse(val accessToken: String)
@RestController
@RequestMapping("/auth")
class AuthController(private val authService: AuthService) {
@PostMapping("/sign-in")
fun signIn(@Valid @RequestBody request: SignInRequest): SignInResponse =
SignInResponse(authService.sign(request.userId))
}No password field on the request DTO at all. Go's version didn't have one either — just a userId field and nothing to check against:
type SignInRequest struct {
UserID string `json:"userId"`
}
func (h *AuthHandler) SignIn(w http.ResponseWriter, r *http.Request) {
var body SignInRequest
json.NewDecoder(r.Body).Decode(&body)
accessToken, _ := h.jwtService.Sign(body.UserID)
// ...
}The Java implementation was the most self-aware about it — a comment in the original service admitted the gap outright: "no separate credential store exists — issues a token for the given userId without any credential check."
The Fix
The fix is the same shape in every language: a real Credential Aggregate holding a password hash, a PasswordHasher Technical Service (bcrypt, strength 12), and a lookup-then-verify sequence before any token gets issued:
public SignInResult signIn(SignInCommand command) {
Credential credential = credentialQuery
.findCredentials(new CredentialFindQuery(0, 1, command.userId()))
.credentials().stream().findFirst()
.orElseThrow(() -> new AuthException(
AuthException.ErrorCode.INVALID_CREDENTIALS, "Invalid ID or password."));
if (!passwordHasher.verify(command.password(), credential.getPasswordHash())) {
throw new AuthException(
AuthException.ErrorCode.INVALID_CREDENTIALS, "Invalid ID or password.");
}
// ...only then does JWT issuance happen
}Notice a nonexistent user and a wrong password both throw the identical INVALID_CREDENTIALS/401 — repeated verbatim in all five languages. Returning a different error for "no such user" than for "wrong password" turns a login form into a user-enumeration oracle. It's a small detail, but it's the kind of detail that has to be a deliberate convention, not something each language port re-derives on its own.
What Catching It For Real Looked Like
Every language shipped a brand-new E2E suite specifically asserting a 401 for bad credentials — something none of them had before:
it('returns 401 with INVALID_CREDENTIALS when the password is wrong', async () => {
await request(app.getHttpServer())
.post('/auth/sign-up')
.send({ userId: 'owner-2', password: 'password123!' })
.expect(201)
const response = await request(app.getHttpServer())
.post('/auth/sign-in')
.send({ userId: 'owner-2', password: 'wrong-password' })
.expect(401)
expect(response.body).toMatchObject({ code: 'INVALID_CREDENTIALS' })
})The Bug the Bug Fix Found
Writing that test was the first time, in either the java-springboot or the kotlin-springboot port, that a test actually asserted a real 401 response. That turned out to matter: Spring's default TestRestTemplate request factory is built on the JDK's own HttpURLConnection, which has a documented quirk — a POST that gets back a 401 throws IOException: cannot retry due to server authentication, in streaming mode instead of just returning the response. The test itself was correct; the default HTTP client underneath it couldn't handle its own result.
The fix swaps the request factory for one built on Apache's httpclient5, which doesn't have this limitation:
// TestRestTemplate's default request factory (JDK HttpURLConnection-based) has a known
// limitation: it throws "cannot retry due to server authentication, in streaming mode" when
// a POST gets a 401 back. This test asserts a real sign-in failure (401), so swap in the
// httpclient5-based factory instead (see build.gradle's testImplementation httpclient5).
@BeforeEach
void useApacheHttpClientRequestFactory() {
restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory())
}A Second Bug, Same Blast Radius
The Java fix also caught something unrelated to authentication logic entirely: SecurityConfig didn't permitAll on /error. When Bean Validation rejected a bad request to the unauthenticated /auth/sign-up endpoint, the servlet container's internal redispatch to /error got re-run through the Spring Security filter chain — and an intended 400 came back as a 401 instead. A new password-length validation test caught it; the fix was one line, adding /error to the existing permit-list alongside the auth endpoints themselves.
What's Actually Guarding This Now
Worth being precise about what "fixed" means here: there's no dedicated harness rule that mechanically checks "sign-in must verify a password hash" — that's a business-logic assertion, and this repo's harnesses deliberately stay out of business logic. What actually guards against a regression is the E2E suite itself, one per language, asserting 401/INVALID_CREDENTIALS for both a nonexistent user and a wrong password, plus the sign-up validation cases. That's a real, permanent safety net — just not a structural one.
docs/architecture/authentication.md — the full JWT/credential-verification flow · AuthControllerE2ETest.java — the real E2E suite, including the 401/retry-bug fix