Spring Security
Letting Anonymous Users Act: Participant Tokens in Spring Security
How to enforce one vote per person, per-session scoping and moderation rules for users who never create an account — a second JWT issuer, a published JWK set, and a unique constraint on an identity that is not a person.
Most authentication tutorials answer the question "who is this person?". This one answers a harder question: "how do I let someone act, safely, when I have deliberately decided never to find out who they are?"
The problem
I was building Interacta, a live audience-engagement platform. A speaker runs a session, puts a join code on the screen, and the room asks questions, upvotes them and votes in polls — in real time, from their phones.
The product constraint was simple and non-negotiable: the audience never creates an account. Nobody sitting in a conference hall is going to register, verify an email and pick a password in order to ask one question. If the join flow is longer than scanning a QR code, the feature is dead.
But "no account" is not the same as "no rules". The backend still had to enforce:
- One upvote per person per question. Not one per click.
- One ballot per person per poll.
- A question submitted in session A must never touch session B.
- A listener must not be able to read the moderation queue, which contains rejected questions.
- All of this without storing a single piece of personally identifying information about the listener.
The last two lines are the interesting part. Authorization normally hangs off an identity. Take the identity away and most of the usual toolkit stops working.
Why the obvious answers do not work
Spring Security anonymous authentication. ROLE_ANONYMOUS is the same principal for every unauthenticated caller. It tells you nothing about which anonymous caller you are talking to, so it cannot enforce one vote per person.
A session cookie. This works, right up until you remember that the point of a stateless service is not keeping server-side session state, and that the frontend, the gateway and five services would all need to agree about the cookie. It also fails the moment a listener has third-party cookies locked down.
Rate limiting by IP. A conference hall is one NAT. A campus is one NAT. Half the audience shares an address, so IP-based deduplication either lets one person vote fifty times or lets one person block everyone else in the room.
A client-generated ID in localStorage. This is the one that looks like it works. The client makes up a UUID, sends it as a header, the server counts votes against it. It is also trivially forgeable: open devtools, change the string, vote again. Anything the client can invent, the client can invent twice.
The common failure in all four is the same. A voter identity has to be issued by the server and unforgeable by the client — it just does not have to be a person.
The move: a second token issuer
The answer was to stop thinking of the listener as unauthenticated. A listener is authenticated. They are simply authenticated as a random number with a very short lease.
When someone joins with a code, session-service mints them a signed JWT:
@Service
public class ParticipantTokenService {
private final JWSSigner signer;
private final String issuer;
private final Duration ttl;
private final String keyId;
public ParticipantToken mint(Session session) {
Instant now = Instant.now();
String participantId = UUID.randomUUID().toString();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(issuer) // NOT keycloak
.subject(participantId) // a random number, not a person
.claim("sessionId", session.getId().toString())
.claim("joinCode", session.getJoinCode())
.claim("role", "PARTICIPANT")
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(now))
.expirationTime(Date.from(now.plus(ttl)))
.build();
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keyId).build(),
claims);
jwt.sign(signer);
return new ParticipantToken(jwt.serialize(), ttl.toSeconds(), participantId);
}
}Read the claim set again and notice what is absent. No email. No name. No username. No device fingerprint. The sub is a UUID generated at join time and never written next to anything that identifies a human being.
What the token does carry is authority: it is signed with RS256 by a key only session-service holds, so the client cannot mint one, and cannot edit the sessionId in the one they were given.
The join endpoint is public, and that is fine — it is public in the same way a door is public:
@PostMapping("/api/v1/sessions/join/{joinCode}/participant")
public ResponseEntity<ParticipantTokenResponse> join(@PathVariable String joinCode) {
Session session = sessions.findByJoinCode(joinCode)
.orElseThrow(() -> new NotFoundException("No session for that code"));
if (session.getStatus() == SessionStatus.ENDED) {
throw new ConflictException("That session has ended");
}
return ResponseEntity.ok(tokenService.mint(session));
}Publishing the keys
A token nobody else can verify is useless. Five services need to check these tokens, and none of them should be handed a signing secret to do it — that would turn every service into a service that can forge participant tokens.
So session-service does what any OIDC provider does: it publishes the public half as a JWK Set.
@RestController
public class JwksController {
private final JWKSet publicKeys;
@GetMapping(path = "/oauth2/jwks", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> jwks() {
return publicKeys.toJSONObject(); // public parameters only
}
}That single endpoint is what turns an internal implementation detail into a standard. Every consumer now uses the same Spring Security machinery it already uses for Keycloak, pointed at a different URL.
Two issuers, one resource server
Here is the part that surprised me: Spring Security supports this out of the box, and has since 5.3. You do not need a custom filter.
JwtIssuerAuthenticationManagerResolver reads the iss claim before validating anything else, and routes the token to the right AuthenticationManager:
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Value("${keycloak.issuer}") private String keycloakIssuer;
@Value("${keycloak.jwk-set-uri}") private String keycloakJwks;
@Value("${participant.issuer}") private String participantIssuer;
@Value("${participant.jwk-set-uri}") private String participantJwks;
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(CsrfConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth ->
oauth.authenticationManagerResolver(issuerResolver()))
.build();
}
private AuthenticationManagerResolver<HttpServletRequest> issuerResolver() {
Map<String, AuthenticationManager> managers = Map.of(
keycloakIssuer, manager(keycloakDecoder()),
participantIssuer, manager(participantDecoder()));
return new JwtIssuerAuthenticationManagerResolver(managers::get);
}
private AuthenticationManager manager(JwtDecoder decoder) {
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder);
provider.setJwtAuthenticationConverter(converterFor(decoder));
return provider::authenticate;
}
}Each issuer gets its own decoder and its own authority converter. Keycloak realm roles become ROLE_HOST, ROLE_ADMIN and so on; a participant token becomes exactly one authority:
public class ParticipantAuthorityConverter
implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// A participant token grants one thing and only one thing.
return List.of(new SimpleGrantedAuthority("ROLE_PARTICIPANT"));
}
}The staff side of the application does not change at all. It never learns that a second issuer exists.
The check that actually matters
ROLE_PARTICIPANT is nearly worthless on its own. It says "this caller joined a session", not "this caller joined this session". Without a second check, a token minted for a session you were invited to would let you upvote in a session you were not.
So every handler that touches a session asserts that the token's sessionId claim matches the session being acted on. I wrapped it up in a CallerContext so it could not be forgotten:
@Component
public class CallerContext {
/** The participant id, or the Keycloak subject — whoever is asking. */
public String voterId(Jwt jwt) {
return jwt.getSubject();
}
public boolean isParticipantOf(Jwt jwt, UUID sessionId) {
return "PARTICIPANT".equals(jwt.getClaimAsString("role"))
&& sessionId.toString().equals(jwt.getClaimAsString("sessionId"));
}
/** Throws unless the caller hosts this session, or is scoped to it. */
public void requireAccessTo(Jwt jwt, SessionRef session) {
if (isParticipantOf(jwt, session.getId())) return;
if (session.getHostId().equals(jwt.getSubject())) return;
throw new ForbiddenException("Not your session");
}
}Two rules, applied identically in every service: is this caller the host of this session, or a participant scoped to it? Everything else falls out of that.
One vote per person, without knowing the person
This is where the design pays for itself. Because the participant id is server-issued and unforgeable, it can go straight into a unique constraint:
@Entity
@Table(name = "question_upvotes",
uniqueConstraints = @UniqueConstraint(
name = "uk_upvote_question_voter",
columnNames = {"question_id", "voter_id"}))
public class QuestionUpvote {
@Id @GeneratedValue
private UUID id;
@Column(name = "question_id", nullable = false)
private UUID questionId;
/** The participant id from the token. Not a user, not a person. */
@Column(name = "voter_id", nullable = false, length = 64)
private String voterId;
@CreationTimestamp
private Instant createdAt;
}The database enforces the rule. Not the UI, not a service-layer if, not a client-side disabled button — the schema. A retry, a double-tap, a replayed request and a determined person with curl all hit the same constraint.
Pre-check before the insert so an honest duplicate gets an honest answer instead of a 500:
@Transactional
public void upvote(UUID questionId, String voterId) {
if (upvotes.existsByQuestionIdAndVoterId(questionId, voterId)) {
throw new ConflictException("Already counted");
}
upvotes.save(new QuestionUpvote(questionId, voterId));
events.publish(new QuestionUpvotedEvent(questionId));
}Anonymity and accountability turn out not to be opposites. You just have to be precise about which one you actually needed.
What you give up
No design is free, and pretending otherwise is how people get burned.
Revocation is gone. A stateless JWT is valid until it expires. There is no session table to delete a row from. The mitigation is a short TTL — six hours by default, roughly the length of an event — and accepting that a badly behaved participant can be silenced at the content level rather than the token level. If you need true revocation, you need a deny-list, and you have just reintroduced the state you were avoiding.
Restart invalidates everything, if your key is ephemeral. In development the signing key is generated at boot, so every restart orphans every outstanding token. That is fine locally and catastrophic in production. Load a stable key from configuration before you deploy. I left a note in the README about this specifically because it is the kind of thing that is obvious right up until it is 2am.
No identity across devices. A participant token is bound to the browser that requested it. Switch phones and you are a new person with new votes. For a live session that is acceptable. For anything that needs continuity, it is not.
Clock skew matters. Two services validating exp against drifting clocks will disagree about whether a token is alive. Run NTP.
When to reach for this
The pattern generalises past live events. It fits anywhere you need per-actor rules without per-actor accounts:
- Poll or survey links where one response per recipient matters.
- Guest checkout that has to survive several requests.
- A support chat widget where the visitor is scoped to one conversation.
- Any QR-code-driven interaction: table ordering, event check-in, classroom feedback.
The recipe is always the same four steps. Issue a signed token from the server. Publish the public key. Route by issuer so your existing security config keeps working. Put the server-issued id in a unique constraint.
The insight worth keeping is that authentication and identification are not the same thing. You can prove a caller is the same caller as last time without ever learning, or storing, who they are — and for a great many features, that is all the proof the rules actually needed.