Invite codes are brute-forceable (insufficient entropy) #2

Open
opened 2026-04-02 11:20:11 +02:00 by marcel · 5 comments
Owner

Problem

Invite codes are 8-character alphanumeric strings ([A-Z0-9]), giving only ~41.4 bits of entropy (36^8 ≈ 2.8 trillion). Combined with no rate limiting on the accept endpoint, this is brute-forceable.

Affected files

  • HouseholdService.java:175-180generateInviteCode() method
  • HouseholdService.java:40CODE_CHARS constant

Attack scenario

At 1000 requests/second with no rate limiting, an attacker could enumerate the code space in a feasible timeframe. Multiple active invites increase the probability of a hit.

Replace the 8-char alphanumeric code with UUIDv4 (122 bits of entropy) as the invite code. The spec already calls for "UUIDv4 minimum." This also requires updating the invite_code column length constraint.

Rate limiting on the accept endpoint (see separate issue) provides defense-in-depth.

Severity

Critical — invite codes can be guessed, granting unauthorized household access.

## Problem Invite codes are 8-character alphanumeric strings (`[A-Z0-9]`), giving only ~41.4 bits of entropy (`36^8 ≈ 2.8 trillion`). Combined with no rate limiting on the accept endpoint, this is brute-forceable. ## Affected files - `HouseholdService.java:175-180` — `generateInviteCode()` method - `HouseholdService.java:40` — `CODE_CHARS` constant ## Attack scenario At 1000 requests/second with no rate limiting, an attacker could enumerate the code space in a feasible timeframe. Multiple active invites increase the probability of a hit. ## Recommended fix Replace the 8-char alphanumeric code with UUIDv4 (122 bits of entropy) as the invite code. The spec already calls for "UUIDv4 minimum." This also requires updating the `invite_code` column length constraint. Rate limiting on the accept endpoint (see separate issue) provides defense-in-depth. ## Severity Critical — invite codes can be guessed, granting unauthorized household access.
marcel added the kind/securitypriority/critical labels 2026-04-02 11:20:55 +02:00
Author
Owner

👨‍💻 Kai — Frontend Engineer

This is entirely a backend change, but the invite acceptance flow touches frontend routing and I want to flag a few things.

Frontend impact of switching to UUIDv4 invite codes:

  • URL length: Current 8-char codes produce short, shareable URLs like /invites/ABC12345/accept. A UUIDv4 code produces /invites/550e8400-e29b-41d4-a716-446655440000/accept. The URL is longer but still shareable as a link — this is fine. I just need to update any route parameter validation in the SvelteKit route if it currently enforces a length/pattern.
  • Copy-to-clipboard UX: If the invite flow has a "copy invite link" button, it currently works fine with either format since it copies the full URL. No change needed there.
  • Manual entry: If there's any UI that lets users type in an invite code manually (rather than clicking a link), a 36-char UUID is hostile to that use case. Are there any screens where users enter the code manually, or is it always link-based?

Questions:

  • Does the invite_code column have a length constraint that needs a migration when we switch from varchar(8) to varchar(36)? Kai can't handle DB migrations, but I need to know if this is a breaking change for the /invites/{code}/accept route parameter.
  • Is there a spec for the invite flow UI (screens J5/J6 from the user journey)? I don't have a design for the accept-invite landing page yet — if the code format changes, I'd like Atlas to spec it before implementation.

No blockers on my end — the UUIDv4 switch is transparent to the frontend as long as the route parameter accepts the new format.

## 👨‍💻 Kai — Frontend Engineer This is entirely a backend change, but the invite acceptance flow touches frontend routing and I want to flag a few things. **Frontend impact of switching to UUIDv4 invite codes:** - **URL length:** Current 8-char codes produce short, shareable URLs like `/invites/ABC12345/accept`. A UUIDv4 code produces `/invites/550e8400-e29b-41d4-a716-446655440000/accept`. The URL is longer but still shareable as a link — this is fine. I just need to update any route parameter validation in the SvelteKit route if it currently enforces a length/pattern. - **Copy-to-clipboard UX:** If the invite flow has a "copy invite link" button, it currently works fine with either format since it copies the full URL. No change needed there. - **Manual entry:** If there's any UI that lets users type in an invite code manually (rather than clicking a link), a 36-char UUID is hostile to that use case. Are there any screens where users enter the code manually, or is it always link-based? **Questions:** - Does the `invite_code` column have a length constraint that needs a migration when we switch from `varchar(8)` to `varchar(36)`? Kai can't handle DB migrations, but I need to know if this is a breaking change for the `/invites/{code}/accept` route parameter. - Is there a spec for the invite flow UI (screens J5/J6 from the user journey)? I don't have a design for the accept-invite landing page yet — if the code format changes, I'd like Atlas to spec it before implementation. **No blockers on my end** — the UUIDv4 switch is transparent to the frontend as long as the route parameter accepts the new format.
Author
Owner

🏗️ Backend Engineer — Spring Boot / PostgreSQL Specialist

Agreed on the fix — UUIDv4 is the right call. The implementation details matter here.

Implementation specifics:

  1. Code generation: Replace the custom generateInviteCode() with UUID.randomUUID().toString(). That's it. No custom alphabet, no length calculation — just use the standard library.

  2. DB column migration: The invite_code column is almost certainly varchar(8) or similar. A Flyway migration is needed to widen it to varchar(36) (or uuid type). Using the PostgreSQL native uuid type is cleaner: ALTER TABLE invites ALTER COLUMN invite_code TYPE uuid USING invite_code::uuid. This also gives us free format validation at the DB level.

  3. Application layer: If invite_code changes to uuid type in PostgreSQL, the JPA mapping should use UUID (Java type), not String. Spring Data JPA handles UUID ↔ PostgreSQL uuid natively.

  4. CODE_CHARS constant: The HouseholdService.java:40 CODE_CHARS constant can be deleted entirely — no longer needed.

  5. Existing data: Any existing 8-char invite codes in the database become invalid after migration. Is this acceptable? If there are outstanding invites, they'll need to be regenerated or expired first. For v1 / development, this is probably fine — just worth calling out.

Questions:

  • Is the invite_code column currently varchar(n) or something else? I want to confirm the migration path.
  • Are invite codes indexed? They should have a UNIQUE index for fast lookup — and if not, that needs to be added in the same migration.
  • Should the migration expire all existing invite codes as part of the switch? Or is there no production data to worry about yet?

Defense in depth: Rate limiting on the accept endpoint (issue #1) should be implemented alongside this — the two fixes together close the brute-force attack. Either fix alone is incomplete.

## 🏗️ Backend Engineer — Spring Boot / PostgreSQL Specialist Agreed on the fix — UUIDv4 is the right call. The implementation details matter here. **Implementation specifics:** 1. **Code generation:** Replace the custom `generateInviteCode()` with `UUID.randomUUID().toString()`. That's it. No custom alphabet, no length calculation — just use the standard library. 2. **DB column migration:** The `invite_code` column is almost certainly `varchar(8)` or similar. A Flyway migration is needed to widen it to `varchar(36)` (or `uuid` type). Using the PostgreSQL native `uuid` type is cleaner: `ALTER TABLE invites ALTER COLUMN invite_code TYPE uuid USING invite_code::uuid`. This also gives us free format validation at the DB level. 3. **Application layer:** If `invite_code` changes to `uuid` type in PostgreSQL, the JPA mapping should use `UUID` (Java type), not `String`. Spring Data JPA handles `UUID` ↔ PostgreSQL `uuid` natively. 4. **`CODE_CHARS` constant:** The `HouseholdService.java:40` `CODE_CHARS` constant can be deleted entirely — no longer needed. 5. **Existing data:** Any existing 8-char invite codes in the database become invalid after migration. Is this acceptable? If there are outstanding invites, they'll need to be regenerated or expired first. For v1 / development, this is probably fine — just worth calling out. **Questions:** - Is the `invite_code` column currently `varchar(n)` or something else? I want to confirm the migration path. - Are invite codes indexed? They should have a `UNIQUE` index for fast lookup — and if not, that needs to be added in the same migration. - Should the migration expire all existing invite codes as part of the switch? Or is there no production data to worry about yet? **Defense in depth:** Rate limiting on the accept endpoint (issue #1) should be implemented alongside this — the two fixes together close the brute-force attack. Either fix alone is incomplete.
Author
Owner

🧪 QA Engineer

Here's the full test coverage I'd want for this fix, including the migration path.

Unit tests for HouseholdService.generateInviteCode() (or its replacement):

  • The generated code is a valid UUID format (matches UUID regex pattern)
  • Two generated codes are not equal (probabilistic, but worth asserting for basic sanity)
  • The code length is exactly 36 characters (UUID string format)

Integration tests for invite acceptance:

  • POST /v1/invites/{validUUID}/accept with valid, unexpired code → 200, user added to household
  • POST /v1/invites/{validUUID}/accept with expired code → 410 (Gone) or 404 — what's the intended status?
  • POST /v1/invites/{validUUID}/accept with already-used code → 409 or 404
  • POST /v1/invites/{invalidFormat}/accept — e.g., "ABC12345" (old format) or "not-a-uuid"400
  • POST /v1/invites/{nonExistentUUID}/accept — valid UUID format but doesn't exist → 404
  • Unauthenticated request to accept invite → 401
  • Authenticated user who is already a member of the household accepts invite → what happens? 409?

Migration test:

  • Flyway migration runs cleanly against a fresh PostgreSQL container
  • Old 8-char codes in the DB (if any) are handled correctly by the migration (or rejected — document the behavior)
  • The UNIQUE constraint on invite_code is present after migration

Edge cases:

  • What if the same UUIDv4 is generated twice? (astronomically unlikely, but the UNIQUE constraint should catch it and the service should handle the constraint violation gracefully — not return a 500)
  • UUID case sensitivity: are lookups case-insensitive? PostgreSQL's uuid type normalizes case, but varchar does not. This matters if the code is passed via URL and the case differs.

Note: These tests should be coupled with rate limiting tests from issue #1 — the combination of UUIDv4 + rate limiting is what makes the fix complete.

## 🧪 QA Engineer Here's the full test coverage I'd want for this fix, including the migration path. **Unit tests for `HouseholdService.generateInviteCode()` (or its replacement):** - The generated code is a valid UUID format (matches UUID regex pattern) - Two generated codes are not equal (probabilistic, but worth asserting for basic sanity) - The code length is exactly 36 characters (UUID string format) **Integration tests for invite acceptance:** - `POST /v1/invites/{validUUID}/accept` with valid, unexpired code → `200`, user added to household - `POST /v1/invites/{validUUID}/accept` with expired code → `410` (Gone) or `404` — what's the intended status? - `POST /v1/invites/{validUUID}/accept` with already-used code → `409` or `404` - `POST /v1/invites/{invalidFormat}/accept` — e.g., `"ABC12345"` (old format) or `"not-a-uuid"` → `400` - `POST /v1/invites/{nonExistentUUID}/accept` — valid UUID format but doesn't exist → `404` - Unauthenticated request to accept invite → `401` - Authenticated user who is already a member of the household accepts invite → what happens? `409`? **Migration test:** - Flyway migration runs cleanly against a fresh PostgreSQL container - Old 8-char codes in the DB (if any) are handled correctly by the migration (or rejected — document the behavior) - The `UNIQUE` constraint on `invite_code` is present after migration **Edge cases:** - What if the same UUIDv4 is generated twice? (astronomically unlikely, but the `UNIQUE` constraint should catch it and the service should handle the constraint violation gracefully — not return a 500) - UUID case sensitivity: are lookups case-insensitive? PostgreSQL's `uuid` type normalizes case, but `varchar` does not. This matters if the code is passed via URL and the case differs. **Note:** These tests should be coupled with rate limiting tests from issue #1 — the combination of UUIDv4 + rate limiting is what makes the fix complete.
Author
Owner

🔒 Sable — Security Engineer

This is correctly rated Critical. The math in the issue description undersells the risk slightly — let me sharpen it.

Why 41.4 bits is insufficient:

The issue says ~2.8 trillion combinations. That sounds large, but the relevant metric is how many guesses an attacker needs to achieve a given probability of success. With multiple active invite codes (common in a household app where several people are invited simultaneously), the probability of a hit per request scales linearly with the number of active codes. With no rate limiting, even a modest botnet can enumerate at millions of requests per second.

Security requirements for the fix:

  • UUIDv4 is the minimum, not the maximum: 122 bits of entropy from UUID.randomUUID() (Java's SecureRandom backed) is correct. Do not use Math.random() or any non-cryptographically-secure random source for invite code generation.
  • Single-use enforcement: A code must be marked used (or deleted) immediately upon acceptance. The acceptance must be atomic — check validity and mark used in a single transaction to prevent race conditions where two requests accept the same code concurrently.
  • Expiration: Invite codes should expire. The spec mentions UUIDv4 as the minimum — but entropy alone doesn't limit exposure time. A UUIDv4 code that never expires is still a permanent access grant if the link leaks (e.g., shared accidentally). What's the intended expiry window? 7 days? 24 hours?
  • Rate limiting: UUIDv4 makes brute force computationally infeasible, but rate limiting on /v1/invites/*/accept is still valuable defense-in-depth (issue #1 covers this). These two fixes are complementary.

Atomicity concern: The current HouseholdService.java — is the "check code valid → accept → mark used" operation wrapped in a single @Transactional boundary? If not, two concurrent requests with the same code could both pass the validity check before either marks it used. The DB UNIQUE constraint on the code is not sufficient here — we need the update to be atomic with the lookup.

## 🔒 Sable — Security Engineer This is correctly rated Critical. The math in the issue description undersells the risk slightly — let me sharpen it. **Why 41.4 bits is insufficient:** The issue says ~2.8 trillion combinations. That sounds large, but the relevant metric is how many guesses an attacker needs to achieve a given probability of success. With multiple active invite codes (common in a household app where several people are invited simultaneously), the probability of a hit per request scales linearly with the number of active codes. With no rate limiting, even a modest botnet can enumerate at millions of requests per second. **Security requirements for the fix:** - **UUIDv4 is the minimum, not the maximum:** 122 bits of entropy from `UUID.randomUUID()` (Java's `SecureRandom` backed) is correct. Do not use `Math.random()` or any non-cryptographically-secure random source for invite code generation. - **Single-use enforcement:** A code must be marked used (or deleted) immediately upon acceptance. The acceptance must be atomic — check validity and mark used in a single transaction to prevent race conditions where two requests accept the same code concurrently. - **Expiration:** Invite codes should expire. The spec mentions UUIDv4 as the minimum — but entropy alone doesn't limit exposure time. A UUIDv4 code that never expires is still a permanent access grant if the link leaks (e.g., shared accidentally). What's the intended expiry window? 7 days? 24 hours? - **Rate limiting:** UUIDv4 makes brute force computationally infeasible, but rate limiting on `/v1/invites/*/accept` is still valuable defense-in-depth (issue #1 covers this). These two fixes are complementary. **Atomicity concern:** The current `HouseholdService.java` — is the "check code valid → accept → mark used" operation wrapped in a single `@Transactional` boundary? If not, two concurrent requests with the same code could both pass the validity check before either marks it used. The DB `UNIQUE` constraint on the code is not sufficient here — we need the update to be atomic with the lookup.
Author
Owner

🎨 Atlas — UI/UX Designer

This is a backend security fix, but the invite flow has clear UX implications that I want to think through while it's being touched.

UX impact of switching to UUIDv4:

  • Link sharing remains the primary flow — the code format doesn't matter to users as long as the link works. A UUID in a URL is completely standard and unremarkable.
  • Manual code entry is no longer viable — if any part of the current design allows users to type an invite code manually (e.g., "Enter your invite code" input field), that UX must be removed or replaced with link-only invites. Nobody is typing a UUID. Is there a "manual code entry" flow in the current design? I don't recall speccing one, but I want to confirm.

Invite UX states I want to design (if not already done):

  • Invite link landing page — what does a new user see when they click an invite link? This is the first screen many users will ever see.
  • Expired code state — "This invite link has expired. Ask your household to send a new one." Clear and actionable.
  • Already-used code state — same as above, or a different message?
  • Already-a-member state — "You're already a member of this household."
  • Invalid code state — generic "This invite link is not valid" without exposing why.

Questions:

  • What is the intended invite expiry window? 7 days is common. 24 hours is more secure. This affects what the expiry message says (e.g., "This link expired N days ago").
  • Is the invite-sender shown who accepted the invite and when? That's a useful household management feature — worth noting for the invite management screen.
  • Are invite links designed to work for users who aren't signed up yet (new user flow) as well as existing users? The landing page design differs significantly between these two cases.
## 🎨 Atlas — UI/UX Designer This is a backend security fix, but the invite flow has clear UX implications that I want to think through while it's being touched. **UX impact of switching to UUIDv4:** - **Link sharing remains the primary flow** — the code format doesn't matter to users as long as the link works. A UUID in a URL is completely standard and unremarkable. - **Manual code entry is no longer viable** — if any part of the current design allows users to type an invite code manually (e.g., "Enter your invite code" input field), that UX must be removed or replaced with link-only invites. Nobody is typing a UUID. Is there a "manual code entry" flow in the current design? I don't recall speccing one, but I want to confirm. **Invite UX states I want to design (if not already done):** - Invite link landing page — what does a new user see when they click an invite link? This is the first screen many users will ever see. - Expired code state — "This invite link has expired. Ask your household to send a new one." Clear and actionable. - Already-used code state — same as above, or a different message? - Already-a-member state — "You're already a member of this household." - Invalid code state — generic "This invite link is not valid" without exposing why. **Questions:** - What is the intended invite expiry window? 7 days is common. 24 hours is more secure. This affects what the expiry message says (e.g., "This link expired N days ago"). - Is the invite-sender shown who accepted the invite and when? That's a useful household management feature — worth noting for the invite management screen. - Are invite links designed to work for users who aren't signed up yet (new user flow) as well as existing users? The landing page design differs significantly between these two cases.
Sign in to join this conversation.