[ANCHOR-1224]: Missing JWT audience/type binding in JwtService → SEP-10 user token usable as Platform-API admin credential under JWT secret reuse#1965
Merged
Conversation
* add explicit aud (audience) claims to all generated jwt tokens * add jwt audience validation during decoding to prevent token type confusion * add a configuration check to reject non-unique jwt secrets
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens JWT type separation across the Anchor Platform by binding each token class to an aud claim during issuance and enforcing that expected audience during decoding, eliminating token-type confusion even if HMAC secrets are accidentally reused. It also adds startup validation to ensure all configured JWT secrets are unique.
Changes:
- Added per-token-type
audstamping across all JWT encoders inJwtService. - Enforced expected
audmembership inJwtService.decode(...)for each supported JWT class. - Added
PropertySecretConfig.validate()checks to reject any equality collisions across the 7 JWT secrets, plus tests covering single and multiple collisions.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| platform/src/test/kotlin/org/stellar/anchor/platform/config/SecretConfigTest.kt | Adds validation tests for JWT secret collisions and distinct-secret success cases. |
| platform/src/main/java/org/stellar/anchor/platform/config/PropertySecretConfig.java | Enforces uniqueness across all configured JWT secrets at startup/validation time. |
| core/src/test/kotlin/org/stellar/anchor/auth/JwtServiceTest.kt | Adds regression tests proving aud prevents cross-type decoding under secret collisions. |
| core/src/main/java/org/stellar/anchor/auth/JwtService.java | Adds audience constants, stamps aud during encoding, and validates aud during decoding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
* refactor jwt service by removing duplicate subject call * update test assertion in secret config test
JiahuiWho
approved these changes
Jul 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The platform issues 7 functionally distinct JWT types (
Sep10Jwt,Sep45Jwt,Sep24InteractiveUrlJwt,Sep24MoreInfoUrlJwt,Sep6MoreInfoUrlJwt,CallbackAuthJwt,PlatformAuthJwt), each signed with a separate HMAC secret. Tokens carried noaudclaim - the only thing distinguishing a platform admin token from a user SEP-10 session token was which secret signed it.JwtService.decodeselected the secret by the requested Java class and verified signature and expiry only; if any two secrets shared the same value, a token from one context was byte-for-byte valid in the other.PropertySecretConfig.validate()checked only the SEP-45 secret for cryptographic weakness and never checked whether any two secrets were equal, so the collapse went undetected at startup. The canonicaldev.envexample reused identical placeholder strings across distinct secret slots, normalising the dangerous configuration.If an operator set any two of the 7 secrets to the same value, a token the attacker already held - any completed SEP-10 login, or a SEP-24 interactive URL handed to the user's browser — became a valid Platform API admin credential: read all customer transactions, forge money-movement events (
request_offchain_funds,notify_offchain_funds_received), and drive transactions to terminal states.The fix adds two independent, layered defences: audience binding closes the vulnerability regardless of configuration; the startup equality check makes the dangerous configuration impossible to deploy.
Changes
JwtService: added 7AUD_*string constants, one per token type ("sep10","sep45","sep24_interactive","sep24_more_info","sep6_more_info","callback_api","platform_api").JwtService.encode(WebAuthJwt): stampsaud=sep10oraud=sep45via.audience().add(aud).and()before signing.JwtService.encode(MoreInfoUrlJwt): stampsaud=sep6_more_infooraud=sep24_more_infobased on token type.JwtService.encode(Sep24InteractiveUrlJwt): stampsaud=sep24_interactive.JwtService.encode(ApiAuthJwt, String, String): private method gains anaudparameter;encode(CallbackAuthJwt)andencode(PlatformAuthJwt)passAUD_CALLBACK_APIandAUD_PLATFORM_APIrespectively.JwtService.decode: tracksexpectedAudper class alongsidesecretin the existing dispatch chain; after signature verification, extractsClaims.getAudience()and throwsJwtExceptionif the set is null or does not containexpectedAud.PropertySecretConfig.validate(): collects all 7 configured JWT secrets into a map (skipping null/empty), checks every pair for equality, and rejects with error codesecrets.jwt.must_be_uniqueon any collision, naming both config keys in the message.Acceptance Criteria
Sep10Jwtencoded and decoded asSep10Jwtround-trips correctly (all existing roundtrip tests pass unchanged).Sep10Jwtcipher is rejected when decoded asPlatformAuthJwt, even whensep10JwtSecret == platformAuthSecret.PlatformAuthJwtcipher is rejected when decoded asSep10Jwt, even when secrets collide.PropertySecretConfig.validate()emitssecrets.jwt.must_be_uniquefor every colliding pair when any two secrets share a value.PropertySecretConfig.validate()passes without error when all 7 configured secrets are distinct.Context
#3810399
Testing
./gradlew :core:test --tests "org.stellar.anchor.auth.JwtServiceTest"./gradlew :platform:test --tests "org.stellar.anchor.platform.config.SecretConfigTest"./gradlew :core:testand./gradlew :platform:testDocumentation
N/A
Known limitations
Tokens issued before this change (no
audclaim) will be rejected by the updateddecode. All platform JWT types are short-lived so no active tokens survive a server restart.