As SaaS systems grow, user onboarding shifts from "create an account upfront" to "invite users, let them set their own password." It's a simple feature on the surface where you send a link, user clicks it, account created. But, the moment you add real constraints, it becomes a small system of its own. This post walks through how a typical authorization service stops lying about the security, concurrency, and transactional concerns that most implementations ignore.
The Problem
You need to invite a new user to your system without creating an account upfront. Sound simple? It gets complex fast:
Token security: How do you send a one-time link without exposing the token in logs?
Role validation: If roles change between invite and acceptance, did you accidentally grant them elevated access?
CSRF on public endpoints: Your accept endpoint has no authenticated user. Who validates the origin?
Concurrency: What if two requests try to accept the same token simultaneously?
Email failures: If email sending fails, should the invitation creation rollback?
Most systems lie about one of these. They expose tokens in logs, skip role re-validation at accept time, leave public endpoints open to CSRF, or block on email synchronously. The pattern is straightforward once you see it . Hhash your tokens, re-validate at both boundaries, protect CSRF even without auth, lock on concurrent access, and keep email async but safe.
Related Articles
Shared topics and tags
Newsletter
Expert notes in your inbox
Subscribe for new articles.
How a Typical Authorization Service Handles This
Here's how a Spring Boot authorization service can stop lying about each concern:
1. Token Security: Hash It, Carry It In-Memory Only
Keep sensitive credentials out of your database by generating a disposable raw token in-memory and persisting solely the hashed value.
// Generate: raw token for email, hash for storage
byte[] randomBytes = new byte[32];
new SecureRandom().nextBytes(randomBytes);
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
String tokenHash = sha256(rawToken); // Only this persists
// Event: carry raw token in-memory only
eventPublisher.publishEvent(new UserInvitedEvent(
email,
rawToken, // Never logged, never persisted
invitedByEmail,
expiryDays
));
The pattern:
Generate both raw and hashed versions
Persist only the hash to the database
Carry the raw token through the application event
Send it via email (over TLS)
When the user accepts, hash their input and compare hashes
What this stops: Accidental token exposure in logs, database leaks, or stack traces. If someone dumps the database, they have hashes, not usable tokens.
2. Role Validation at Both Boundaries
Admins sending invitations can only invite users into a whitelist of roles:
private static final Set<String> INVITABLE_ROLES = Set.of(
"ROLE_USER", "ROLE_MERCHANT_ADMIN", "ROLE_SUPPORT_AGENT", "ROLE_OPERATOR"
);
// At invite time
roles.forEach(role -> {
if (!INVITABLE_ROLES.contains(role.getRoleName())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Role '" + role.getRoleName() + "' cannot be assigned via invitation");
}
});
Then, critically, re-validate at accept time:
// At accept time (public endpoint, no auth context)
Set<ApplicationRole> roles = roleIds.stream()
.map(id -> roleRepository.findById(id)
.orElseThrow(InvalidInvitationTokenException::new))
.peek(role -> {
if (!INVITABLE_ROLES.contains(role.getRoleName())) {
throw new InvalidInvitationTokenException(); // Generic error
}
})
.collect(Collectors.toSet());
What this stops: If a role is elevated (ROLE_OPERATOR → ROLE_SYSTEM_ADMIN) after the invite is sent but before it's accepted, the system rejects the accept. If a role is deleted, accept fails with a generic error (not "role not found").
3. CSRF on Public Endpoints
As the accept endpoint is public and there's no authenticated user to check, we still need to protect unauthenticated POST operations against Cross-Site Request Forgery (CSRF) if the browser automatically sends session cookies or if we rely on cookie-based cross-site mechanics. To achieve this without requiring a logged-in user session, we use Spring Security'sCookieCsrfTokenRepositorywithwithHttpOnlyFalse()to issue a statelessly verifiable CSRF cookie that JavaScript can read.
What this stops: An attacker cannot craft a link that, when clicked, accepts an invitation on the user's behalf. The XSRF token is read from a secure cookie (not accessible to scripts), and the form must explicitly include it.
4. Pessimistic Locking for Concurrent Accepts
What if two browsers have the same link and both try to accept simultaneously?
// Repository
@Query("SELECT i FROM UserInvitation i WHERE i.tokenHash = :tokenHash")
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<UserInvitation> findByTokenHashForUpdate(@Param("tokenHash") String tokenHash);
The first transaction locks the row, validates it, and marks it used. The second transaction waits, then finds isUsed() == true, and rejects with InvalidInvitationTokenException.
What this stops: Both creating two users from one invite, or corrupting the invitation state.
5. Async Email, Transactional Safety
Email sending is slow and can fail. We don't want a failed email to rollback the invitation. So we use transactional events:
@Component
public class UserInvitationEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserInvited(UserInvitedEvent event) {
try {
emailService.sendUserInvitationEmail(
UserInvitationEmailCommand.builder()
.toAddress(event.getToEmail())
.invitationLink(buildInvitationLink(event.getRawToken()))
.invitedByEmail(event.getInvitedByEmail())
.expirationWindowText(formatExpiry(event.getExpiryDays()))
.build()
);
} catch (Exception e) {
log.error("Failed to send invitation email: {}", e.getClass().getSimpleName());
// Emit metric for monitoring
meterRegistry.counter("auth.email.failure").increment();
}
}
}
What this stops:
Email timeouts don't rollback the invitation
Failed emails are logged but don't block the admin's workflow
The email is sent after the database commit (no partial state)
If email fails, the user can re-request the invite (the system invalidates old ones)
Key insight: The @Async decorator processes this event in a thread pool after the transaction commits. If the email service throws, the exception is caught and logged—the invitation already exists in the database.
6. Invalidation on Re-invite
If an admin invites the same email twice:
// In sendInvitation()
invitationRepository.invalidateActiveForEmail(dto.getEmail(), now);
This marks all non-expired invites for that email as used_at = now, effectively canceling them. Only the new invite is valid.
What this stops: Confusion about which link is valid, and security issues from multiple outstanding tokens for the same user.
Password too weak: 400 Bad Request with validation rules
Testing This Pattern
Use @RecordApplicationEvents and integration tests to verify the flow:
@Test
@RecordApplicationEvents
void inviteAndAccept_createsUserWithSecurityFlags() throws Exception {
// Step 1: send invitation
mockMvc.perform(post("/api/v1/invitations")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new SendInvitationDTO("newuser@example.com", Set.of(3)))))
.andExpect(status().isNoContent());
// Step 2: capture raw token from published event
String rawToken = applicationEvents.stream(UserInvitedEvent.class)
.filter(e -> "newuser@example.com".equals(e.getToEmail()))
.findFirst()
.orElseThrow()
.getRawToken();
// Step 3: accept via public endpoint with CSRF token
mockMvc.perform(post("/api/v1/invitations/accept")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new AcceptInvitationDTO(rawToken, "New User", "User"))))
.andExpect(status().isNoContent());
// Step 4: verify user flags
var user = userRepository.findByEmail("newuser@example.com").orElseThrow();
assertThat(user.isMfaRequired()).isTrue(); // Forced setup on first login
assertThat(user.isEnabled()).isTrue();
}
Run this against a real database (H2 in tests, PostgreSQL in CI), not an in-memory mock. The real database catches edge cases that mocks hide—like pessimistic locking behavior, transaction isolation, and concurrent access patterns.
The Broader Picture
Email invitations look simple—send a link, user clicks it, account created. But the moment you add real constraints (security, concurrency, transactional safety, CSRF), it becomes a small system. A well-designed authorization service stops lying by baking those constraints into every layer: token handling, role validation, CSRF protection, locking, and transactional event safety.
The pattern isn't unique to email invitations. Password resets follow the same shell—hash the token, carry the raw version in memory, re-validate expiry and usage at submission. Email verification uses the same token lifecycle. API provisioning (issuing a token for OAuth credential generation) is identical structurally, just different semantics.
What Gets Left Out
Most implementations skip the harder pieces:
Rate-limiting invitation sends (who's allowed to invite, how often?)
Confirmation to the admin when the user accepts (audit trail)
Resend functionality (users who lose the email must re-request)
Tracking which invites were accepted vs. ignored (analytics)
These are missing features. But be honest about them: either build them deliberately or acknowledge they're not there.
Final Thought
Next time you're building an invitation system, start here: hash your tokens, re-validate at both boundaries, protect CSRF even without auth, lock on concurrent access, and keep email async but safe. These constraints aren't optional, they're the difference between a feature that looks good in a happy path and one that actually holds up under load, with concurrent requests, and without leaking secrets.
Share this with anyone building their first authorization system.