This article was originally published on Jo4 Blog.
If you're using Auth0 as an OIDC provider for a Spring backend, here's a question worth asking right now: what fields does your backend assume are in the access token, but aren't actually there by default?
For us, the answer was email. And the default behavior, when our user-sync filter found no email claim, was to fall back to a synchronous HTTP call to Auth0's /userinfo endpoint. Per request. With caching, but not enough caching.
Once we noticed, the fix took an afternoon. Here's the whole story.
The Default Auth0 Access Token
Auth0 access tokens are minimal by design. Out of the box, an access token's claims look something like:
{
"iss": "https://your-tenant.auth0.com/",
"sub": "auth0|abc123",
"aud": "https://your-api/",
"iat": 1716950000,
"exp": 1716953600,
"scope": "openid profile email"
}
Notice what's not there: email, name, picture, email_verified. Those live in the ID token by default, or come back from a separate /userinfo call.
Most tutorials encourage you to use the access token for authorization (verify signature, check scopes) and the ID token for user identity. Fine for a SPA. Awkward for a Spring backend that only sees a single bearer token from Authorization: Bearer <jwt> and doesn't know which one it is.
We took a tempting shortcut: assume the access token has email (because some tenants are configured to inline it), and if it doesn't, fall back to /userinfo to grab the rest of the profile.
What That Looked Like
String email = jwt.getClaimAsString("email");
String firstName = jwt.getClaimAsString("given_name");
String lastName = jwt.getClaimAsString("family_name");
String picture = jwt.getClaimAsString("picture");
if (StringUtils.isBlank(email)) {
log.warn("Email claim missing from JWT, falling back to /userinfo");
Auth0UserInfo userInfo = fetchUserInfoFromAuth0(jwt.getTokenValue());
email = userInfo.email;
if (StringUtils.isBlank(firstName)) firstName = userInfo.givenName;
if (StringUtils.isBlank(lastName)) lastName = userInfo.familyName;
if (StringUtils.isBlank(picture)) picture = userInfo.picture;
}
The fallback was wrapped in a Redis cache plus a sync lock, so on a hot path most requests hit cache and skipped the HTTP call. But:
- Cache misses on cold start, after deploys, after Redis restarts, after cache evictions.
- New users always missed the cache by definition.
- The mobile app's startup fan-out (3-5 parallel requests) thundered against the lock, and at least one of them did the actual fetch.
When we measured: a single user could trigger several /userinfo calls per session. Multiplied across the user base and the rate of token rotations, that's a non-trivial dependency on Auth0 staying fast and available — for every API request to our own service.
The Right Fix Is on the Auth0 Side
The clean answer is to never need /userinfo at request time. Put the claims you need into the access token itself, at issue time. Auth0 supports this via Post-Login Actions.
A Post-Login Action runs once per login, on Auth0's side, and can mutate the access token before issuance. The Action that fixed our problem looks like this (Auth0 Action JavaScript):
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://jo4.io/';
api.accessToken.setCustomClaim(`${namespace}email`,
event.user.email);
api.accessToken.setCustomClaim(`${namespace}email_verified`,
event.user.email_verified);
api.accessToken.setCustomClaim(`${namespace}given_name`,
event.user.given_name);
api.accessToken.setCustomClaim(`${namespace}family_name`,
event.user.family_name);
api.accessToken.setCustomClaim(`${namespace}picture`,
event.user.picture);
};
Two important details about that namespace prefix.
Auth0 strips non-namespaced custom claims. If you setCustomClaim('email', ...), Auth0 silently drops it because it conflicts with the OIDC reserved name. Custom claims must be namespaced URIs. We used https://jo4.io/, but it's a symbolic URI — there's no requirement Auth0 actually fetch from it. It just needs to look like a URI.
Match the namespace exactly on the backend. We made the namespace configurable so the Action and the filter can't drift:
@Value("${app.auth0.email-claim-key:https://jo4.io/email}")
private String emailClaimKey;
@Value("${app.auth0.given-name-claim-key:https://jo4.io/given_name}")
private String givenNameClaimKey;
@Value("${app.auth0.picture-claim-key:https://jo4.io/picture}")
private String pictureClaimKey;
And read from those keys directly:
String email = jwt.getClaimAsString(emailClaimKey);
String firstName = jwt.getClaimAsString(givenNameClaimKey);
String lastName = jwt.getClaimAsString(familyNameClaimKey);
String picture = jwt.getClaimAsString(pictureClaimKey);
No fallback. No /userinfo call. The claims either arrive in the access token or they don't, and if they don't, there's a real configuration bug to fix — not a symptom to paper over.
The Graceful Degradation
That said, "no fallback" is too brittle for a deploy where the Action ships before, after, or in the middle of the backend rollout. We added one bounded escape hatch: if the email claim is missing and the user already exists in our DB, log a loud warning and proceed with the existing record:
if (StringUtils.isBlank(email)) {
Optional<UserEntity> existing = userService.findByExternalAuthId(auth0Id);
if (existing.isPresent()) {
log.warn("Email claim '{}' missing from JWT for auth0Id={}; "
+ "using existing DB record. Verify Auth0 Post-Login Action "
+ "sets this custom claim.", emailClaimKey, auth0Id);
userSyncCacheService.markUserSynced(auth0Id, existing.get().getEmail());
return existing.get();
}
log.error("Email claim '{}' missing from JWT and user not found "
+ "by auth0Id={}. Auth0 Post-Login Action must set this custom claim.",
emailClaimKey, auth0Id);
throw new IllegalStateException(
"Email claim missing from JWT — Auth0 Post-Login Action misconfigured");
}
If the Action gets disabled accidentally, existing users keep working (with a noisy warning that you'll see the next time you check your logs). New signups fail fast and loud. The error message names the exact misconfiguration. Future-you will thank present-you.
The Migration Window
We also moved email_verified from the standard claim name into the namespaced one in a separate change. The migration has a wrinkle: tokens issued before the migration still have the old claim, and they don't expire instantly.
So during the window we accepted both:
Boolean emailVerified =
jwt.getClaimAsBoolean("https://jo4.io/email_verified");
if (emailVerified == null || !emailVerified) {
// Tmp fallback for previously issued tokens.
// Remove this if-block after [token-rotation cutoff date].
emailVerified = jwt.getClaimAsBoolean("email_verified");
}
We dated the cleanup in the comment. After the cutoff (when all pre-migration tokens had expired), we deleted the fallback in a separate commit. One commit to migrate, another to clean up — never the same commit, because the cleanup needs to happen on a clock not on a deploy.
Bonus: Lock Release
A subtler bug we found while in there. The original sync code acquired a Redis lock around the userinfo fetch, but on the success path the lock was never explicitly released — it just expired via TTL after 30 seconds. That meant concurrent requests for that user during the next 30 seconds would all skip the sync entirely.
The fix is the obvious one: explicit release in finally:
if (!userSyncCacheService.tryAcquireSyncLock(auth0Id)) {
return userService.findByExternalAuthId(auth0Id).orElse(null);
}
try {
return performSync(jwt, auth0Id);
} finally {
userSyncCacheService.releaseSyncLock(auth0Id);
}
The release is best-effort — if Redis is briefly unavailable, the TTL still cleans up. But on the happy path, the lock evaporates the instant we're done, instead of holding everyone else off for half a minute.
Lessons Learned
-
Auth0 access tokens have minimal claims by default. Don't assume
emailornameare in there — read the actual JWT and find out. - Don't paper over missing claims with a synchronous external call. The cache-the-callout approach scales until it doesn't, and the failure mode is "everything is slow" not "auth is broken," which is harder to diagnose.
- Push enrichment to the issuer. Auth0 Post-Login Actions add custom claims at issue time, once per login, with no per-request cost.
-
Custom claims must be URI-namespaced. Plain names like
emailwill be silently stripped — Auth0 reserves those for OIDC. Use a URI prefix you control. - Configure the namespace, don't hardcode it. Drift between the Action and the backend is a high-impact misconfiguration; making it a single env var prevents it.
-
Always release distributed locks in
finally. Relying on TTL cleanup means your happy path forces unnecessary contention on every other caller for the duration of the TTL.
Has your auth integration ever hidden a per-request external call you weren't aware of? Drop the story in the comments — these are always educational.
Building jo4.io — a URL shortener whose backend doesn't ask Auth0 the same question on every API call.
Top comments (0)