Why Auth0 email_verified Was Missing from My Access Token
Source: Dev.to
The Problem
A Spring Boot filter was rejecting users even though they were marked Verified in the Auth0 dashboard.
Boolean emailVerified = jwt.getClaimAsBoolean("email_verified");
if (emailVerified == null || !emailVerified) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"error\":\"Email not verified\"}");
return;
}
Log output:
User email not verified: sub=auth0|67c6a695657d0f4f7ac8736f
The users were verified. The reason: Auth0 does not include email_verified in access tokens by default—only in ID tokens.
When decoding an access token you might see:
{
"iss": "https://your-tenant.auth0.com/",
"sub": "auth0|67c6a695657d0f4f7ac8736f",
"aud": ["https://your-api/"],
"iat": 1767421775,
"exp": 1767508175,
"scope": "openid profile email"
}
Notice the missing email_verified claim, even though the email scope was requested.
Adding email_verified to the Access Token
Using a Post‑Login Action
- In the Auth0 Dashboard go to Actions → Flows → Login.
- Add a new Action and paste the following code:
exports.onExecutePostLogin = async (event, api) => {
// Add email_verified to access token for API validation
api.accessToken.setCustomClaim('email_verified', event.user.email_verified);
};
- Deploy the Action and drag it into the Login flow.
After deployment, the access token will contain:
{
"email_verified": true,
"iss": "https://your-tenant.auth0.com/",
"sub": "auth0|67c6a695657d0f4f7ac8736f",
...
}
Why This Works
- ID tokens are intended for the client (frontend) to convey identity information.
- Access tokens are meant for the API (backend) to authorize requests.
- Auth0’s default philosophy is to keep identity‑related claims out of access tokens, but many APIs need them. Adding the claim via an Action is the recommended approach.
Alternative: Query the UserInfo Endpoint
If you cannot use Actions, you can fetch the claim from the /userinfo endpoint when it is missing:
if (emailVerified == null) {
String userinfoUrl = auth0Issuer + "userinfo";
// Perform GET with Bearer token
// Parse JSON response for "email_verified"
}
Note: This adds extra latency to every request, so the Action method is generally preferred.
Social Logins
For social connections (Google, GitHub, etc.) Auth0 automatically sets email_verified: true because the provider has already verified the email. You could skip the check for non‑database connections:
String subject = jwt.getSubject();
boolean isDatabaseConnection = subject != null && subject.startsWith("auth0|");
if (isDatabaseConnection) {
// Only check email_verified for username/password users
}
Full Action Example (including optional email resend)
exports.onExecutePostLogin = async (event, api) => {
// Always include email_verified in access token
api.accessToken.setCustomClaim('email_verified', event.user.email_verified);
// Auto‑resend verification email for unverified users (rate‑limited)
if (!event.user.email_verified) {
const lastSent = event.user.user_metadata?.verification_email_last_sent;
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000;
if (!lastSent || (now - lastSent) > ONE_HOUR) {
// Trigger verification email via Management API
// (see Auth0 docs for Management API setup)
api.user.setUserMetadata('verification_email_last_sent', now);
}
}
};
Takeaways
- ID token ≠ Access token – they serve different purposes and contain different claims.
- Use Auth0 Actions to add any custom claim you need to the access token.
- Verify the actual token contents (e.g., with jwt.io) rather than relying solely on the dashboard UI.