Building a Local OIDC Issuer for Secure Webhook Testing
Cloud webhook integrations are hard to debug locally because they usually need a public HTTPS endpoint, a validation handshake, and signed bearer tokens. This guide builds a local test harness with Node.js, Express, jose, and ngrok so you can practise the moving parts safely.
This is a local demo. It does not replace Microsoft Entra ID or production Event Grid authentication. Use it to understand OIDC discovery, JWKS, JWT verification, ngrok tunnelling, and Event Grid-style endpoint validation.
Problem Statement
The debugging problem behind this setup was not simply "can my webhook handle a POST?" It was a layered identity problem with two separate questions:
- Can the webhook receive and answer the Event Grid validation handshake?
- Can a locally issued OIDC token be trusted by a user-assigned managed identity (UAMI) federated credential, exchanged by Microsoft Entra ID, and then validated by the webhook as an Entra-issued access token?
Those questions matter because an Event Grid secure webhook failure can happen at several layers:
- Azure Event Grid validates a webhook endpoint before it creates or updates a subscription.
- Secure webhook delivery can include Microsoft Entra bearer tokens.
- Some designs use a user-assigned managed identity (UAMI) as the delivery identity.
- A UAMI federated credential can trust an external OIDC issuer when the incoming token's
iss,sub, andaudmatch exactly. - Microsoft Entra can exchange that trusted external JWT for an Entra-issued access token.
- The API still has to validate the delivered token's issuer, audience, subject or object ID, expiry, signature, and application roles.
When a subscription fails during Event Grid's preflight or access check, your webhook might never receive a request. That makes normal API logs misleading: the application can be correct, while the Event Grid resource provider fails before delivery starts. Conversely, the Event Grid subscription can be valid while your API rejects the runtime bearer token because the expected identity claims do not match.
The local harness separates those concerns. A local webhook exposed through ngrok proves that endpoint validation and request handling work. A local OIDC issuer proves that your API's JWT validation logic behaves the way you expect. A temporary UAMI federated credential proves that Microsoft Entra can trust the local issuer and exchange the local assertion for an Entra access token. Together, they help narrow the failure to one of four layers: endpoint reachability, local-to-Entra token exchange, API runtime token validation, or Event Grid subscription/preflight authorisation.
Why Local OIDC?
In a UAMI-backed design, the mental model is usually:
- Event Grid uses a managed identity to deliver events.
- The webhook API receives a bearer token.
- The API checks that the token was issued by the expected tenant, for the expected audience, and for the expected delivery principal.
That sounds straightforward until you need to test it locally. You cannot mint arbitrary Microsoft Entra tokens with custom claims, and you should not weaken production validation just to debug a callback. A local OIDC issuer gives you a safe way to test two mechanics independently:
- direct JWT validation against a local issuer, so the webhook's validation code can be tested quickly;
- workload identity federation, where Microsoft Entra trusts the local issuer through a temporary UAMI federated credential and returns an Entra-issued access token.
The first path proves the webhook's token-validation code is sound when a token with the expected shape arrives. The second path proves the local issuer, UAMI federated credential, signed assertion, and Microsoft Entra token endpoint agree on issuer, subject, and audience. Neither path proves Event Grid's deliveryWithResourceIdentity preflight check will pass.
Why ngrok?
Event Grid-style validation requires a public HTTPS endpoint. Localhost is not reachable from the cloud, and self-signed certificates are not a good representation of a real webhook endpoint. ngrok gives you a temporary public HTTPS URL that forwards to your local Express server.
That lets you observe two important things:
- whether the validation handshake reaches the webhook at all;
- whether the webhook returns the exact
validationResponseshape the sender expects.
If ngrok shows no incoming validation request, the problem is probably before your application. If ngrok shows the request but the subscription still fails, inspect the response shape, status code, and headers. If validation succeeds but normal delivery fails, move to bearer-token validation.
What This Does And Does Not Prove
This harness is useful because it reduces a cloud integration into testable parts, but it has limits.
It can prove:
- the webhook route is reachable over HTTPS;
- the Event Grid-style validation response is correct;
- the API accepts a correctly signed JWT from a known issuer;
- the API rejects tokens with the wrong issuer, audience, expiry, or key.
It cannot prove:
- a UAMI has the right Azure RBAC permissions;
- Event Grid's subscription preflight access check will pass;
- Microsoft Entra app-role assignments are correct;
- the first-party Event Grid service principal or a UAMI will receive the exact claims your API expects.
Use the harness to remove your code from the suspect list before you spend time on cloud-side identity and role assignment diagnostics.
Architecture
Local Express app
-> /.well-known/openid-configuration
-> /jwks
-> /webhook
ngrok
-> exposes localhost:3000 as https://example.ngrok.app
Test client
-> sends Event Grid-style validation events
-> sends normal events with a short-lived demo JWT
Optional Entra exchange test
-> local JWT assertion
-> UAMI federated credential
-> Microsoft Entra token endpoint
-> Entra-issued access token
-> webhook bearer-token validation
Cloud investigation, separately
-> checks Event Grid subscription/preflight behaviour
-> checks UAMI or Microsoft.EventGrid app-role assignments
Keep the local test harness and cloud identity investigation separate. The harness is for application behaviour. Azure-side diagnostics are for resource provider checks, app-role assignments, and managed identity configuration.
Prerequisites
npm install express jose
ngrok http 3000
Use the ngrok HTTPS URL as your public base URL. In this article, every URL and identifier is fake:
const PUBLIC_URL = 'https://example.ngrok.app';
const ASSERTION_AUDIENCE = 'api://AzureADTokenExchange';
const WEBHOOK_AUDIENCE = 'api://demo-webhook-api';
const TOKEN_SUBJECT = 'webhook-demo-subject';
const KEY_ID = 'demo-key-id';
Serve OIDC Metadata And JWKS
OpenID Connect discovery exposes issuer metadata at /.well-known/openid-configuration. The JWKS endpoint publishes public signing keys. Keep the private key local and never expose it.
import express from 'express';
import {
SignJWT,
createRemoteJWKSet,
exportJWK,
generateKeyPair,
jwtVerify,
} from 'jose';
const app = express();
app.use(express.json()); // Must be registered before route handlers.
const port = 3000;
const PUBLIC_URL = 'https://example.ngrok.app';
const ASSERTION_AUDIENCE = 'api://AzureADTokenExchange';
const WEBHOOK_AUDIENCE = 'api://demo-webhook-api';
const TOKEN_SUBJECT = 'webhook-demo-subject';
const KEY_ID = 'demo-key-id';
const { publicKey, privateKey } = await generateKeyPair('RS256');
app.get('/.well-known/openid-configuration', (_req, res) => {
res.json({
issuer: PUBLIC_URL,
jwks_uri: `${PUBLIC_URL}/jwks`,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
});
});
app.get('/jwks', async (_req, res) => {
const jwk = await exportJWK(publicKey);
res.json({
keys: [{ ...jwk, kid: KEY_ID, use: 'sig', alg: 'RS256' }],
});
});
For repeatable local testing, load a stable private key from an ignored local file. For production, use a proper key-management system and rotate keys with unique kid values.
Mint A Short-Lived Demo JWT
Use SignJWT to create a token that matches your issuer and audience. For direct local webhook validation, the audience can be your demo webhook audience. For a UAMI federated credential exchange, the assertion audience must match the federated credential audience, normally api://AzureADTokenExchange in the public cloud.
Do not log full tokens in production logs. If you print a token for local curl testing, treat it as a temporary secret.
async function createWebhookDemoJwt(): Promise<string> {
return await new SignJWT({ purpose: 'local-webhook-test' })
.setProtectedHeader({ alg: 'RS256', kid: KEY_ID })
.setIssuer(PUBLIC_URL)
.setAudience(WEBHOOK_AUDIENCE)
.setSubject(TOKEN_SUBJECT)
.setIssuedAt()
.setExpirationTime('10m')
.sign(privateKey);
}
async function createFederatedCredentialAssertion(): Promise<string> {
return await new SignJWT({ purpose: 'uami-federated-credential-test' })
.setProtectedHeader({ alg: 'RS256', kid: KEY_ID })
.setIssuer(PUBLIC_URL)
.setAudience(ASSERTION_AUDIENCE)
.setSubject(TOKEN_SUBJECT)
.setIssuedAt()
.setNotBefore('0s')
.setExpirationTime('10m')
.sign(privateKey);
}
Exchange The Local JWT Through A UAMI Federated Credential
This is the missing bridge between "I can sign a local JWT" and "my webhook can validate an Entra-issued token for the delivery identity".
The flow is:
Local issuer behind ngrok
-> signs short-lived JWT assertion
UAMI federated credential
-> trusts issuer + subject + api://AzureADTokenExchange audience
Microsoft Entra token endpoint
-> validates local JWT against the UAMI federated credential
-> returns an Entra-issued access token for the requested resource
Webhook request
-> Authorization: Bearer <entra-issued-access-token>
-> webhook validates Entra issuer, audience, subject/object ID, roles, and expiry
The federated credential belongs to the user-assigned managed identity, not to the local Express app. The values must match the local assertion exactly:
{
"name": "local-oidc-uami-test",
"issuer": "https://example.ngrok.app",
"subject": "webhook-demo-subject",
"audiences": ["api://AzureADTokenExchange"]
}
The local JWT is then used as a client_assertion in the Microsoft identity platform client credentials flow. The assertion audience is api://AzureADTokenExchange; the final access-token scope is the resource you want the returned Entra token to call.
curl -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=<uami-client-id>" \
--data-urlencode "scope=api://demo-webhook-api/.default" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=<locally-signed-jwt>"
Keep those two audiences separate:
api://AzureADTokenExchangeis the audience on the local JWT assertion and on the UAMI federated credential.api://demo-webhook-api/.defaultis an example final scope for the Entra access token returned by the exchange.
If this call succeeds, you have proved that Microsoft Entra can fetch your local issuer metadata and JWKS, verify the signed assertion, match the UAMI federated credential, and issue an access token. That access token is what you send to the webhook as the bearer token. The webhook should not treat the original local JWT assertion as the production delivery token.
Handle Event Grid-Style Validation
Event Grid validates a webhook by sending a SubscriptionValidation event and expecting a JSON response containing the validation code.
Keep endpoint validation separate from bearer-token authentication. The validation request proves the endpoint can respond; it is not the same thing as authenticating normal event delivery.
function getBearerToken(authorizationHeader: string | undefined): string | null {
if (!authorizationHeader?.startsWith('Bearer ')) {
return null;
}
return authorizationHeader.slice('Bearer '.length);
}
const remoteJwks = createRemoteJWKSet(new URL(`${PUBLIC_URL}/jwks`));
async function verifyBearerToken(authorizationHeader: string | undefined) {
const token = getBearerToken(authorizationHeader);
if (!token) {
throw new Error('Missing bearer token');
}
const { payload } = await jwtVerify(token, remoteJwks, {
issuer: PUBLIC_URL,
audience: WEBHOOK_AUDIENCE,
clockTolerance: 5,
});
return payload;
}
app.post('/webhook', async (req, res) => {
if (req.header('aeg-event-type') === 'SubscriptionValidation') {
const firstEvent = Array.isArray(req.body) ? req.body[0] : undefined;
const validationCode = firstEvent?.data?.validationCode;
if (typeof validationCode !== 'string') {
return res.status(400).json({ error: 'Missing validationCode' });
}
return res.status(200).json({ validationResponse: validationCode });
}
try {
const payload = await verifyBearerToken(req.header('authorization'));
return res.status(202).json({
accepted: true,
subject: payload.sub,
});
} catch {
return res.sendStatus(401);
}
});
app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`);
});
The unauthenticated SubscriptionValidation branch is for local testing. Do not copy it blindly into production without understanding your provider's validation model, source restrictions, and operational controls.
Relating This Back To UAMI Delivery
For a UAMI-backed delivery design, the API usually expects the runtime token to identify the managed identity as the caller. A production validator might check claims such as:
iss: the trusted Microsoft Entra issuer;aud: the webhook application's application ID URI or client ID;tid: the expected tenant;oidorsub: the expected delivery identity object ID;roles: the application role that authorises event delivery.
The local OIDC token in this article is intentionally simpler. It uses iss, aud, sub, expiry, signature, and kid so you can verify the shape of the validator without depending on a real tenant or managed identity. Once the local validator behaves correctly, move back to Azure and compare the real delivered token claims with the claims your API requires.
The federated credential exchange adds one more diagnostic: it proves a locally signed JWT can become an Entra-issued access token when the UAMI federated credential trusts the local issuer. In that exchange, the local JWT is only the client assertion. The webhook still validates the returned Entra access token, not the original local assertion.
If the webhook never receives a request when the subscription is created, do not start by changing API token validation. First prove whether Event Grid reached the ngrok endpoint. If it did not, investigate the subscription destination, secure webhook access check, UAMI permissions, app-role assignments, and whether the chosen Event Grid delivery mode supports the identity model you configured.
Test The Flow
Validate discovery:
curl https://example.ngrok.app/.well-known/openid-configuration
curl https://example.ngrok.app/jwks
Simulate Event Grid validation:
curl -X POST https://example.ngrok.app/webhook \
-H "aeg-event-type: SubscriptionValidation" \
-H "Content-Type: application/json" \
-d '[{"eventType":"Microsoft.EventGrid.SubscriptionValidationEvent","data":{"validationCode":"00000000-0000-0000-0000-000000000000"}}]'
Send a normal event with a short-lived local token:
curl -X POST https://example.ngrok.app/webhook \
-H "Authorization: Bearer <short-lived-demo-jwt>" \
-H "Content-Type: application/json" \
-d '{"message":"hello"}'
Test the UAMI federated credential exchange separately:
- Create a short-lived assertion with
createFederatedCredentialAssertion(). - Configure a temporary UAMI federated credential whose
issuer,subject, andaudiencesmatch the assertion. - Call the Microsoft Entra token endpoint with the assertion as
client_assertion. - Send the returned Entra access token to the webhook as
Authorization: Bearer <entra-issued-access-token>. - Remove the temporary federated credential and local signing material when the test is complete.
Troubleshooting
If verification fails, check these first:
issin the JWT must exactly match the discovery documentissuer.audmust match the verifier's expected audience.- The JWT header
kidmust exist in the JWKS. - The ngrok URL must be current in both
issuerandjwks_uri. - Your machine clock must be accurate, especially for
expandnbf. - Do not log full
Authorizationheaders or JWTs. - For UAMI federated credential exchange, the federated credential
issuer,subject, andaudiencesmust match the local assertion exactly. - If the token exchange succeeds but Event Grid delivery still fails before hitting ngrok, the remaining problem is likely Event Grid subscription/preflight behaviour rather than local token validation.
Public-Safety Checklist
Before publishing examples:
- Replace real tenant IDs, client IDs, object IDs, subscription IDs, and domains with placeholders.
- Use
https://example.ngrok.app, not your live ngrok domain. - Never publish private keys, client secrets, access tokens, refresh tokens, or full JWTs.
- Do not include screenshots that reveal tenant names, usernames, URLs, or portal state.
- Do not disable TLS verification.
- Validate signature, issuer, audience, expiry, and key ID.
- Keep endpoint validation and bearer-token authentication as separate concepts.
- Treat UAMI object IDs, service principal object IDs, app role IDs, and tenant IDs as sensitive environment-specific details.
- Use placeholder values for tenant IDs, client IDs, object IDs, issuer URLs, subjects, resource names, and token values.
- Do not publish ngrok URLs, signed JWTs, Entra access tokens, or decoded token payloads containing environment-specific identifiers.
- Label
api://AzureADTokenExchangeas the federated credential assertion audience, not as the webhook API audience unless the API is explicitly configured that way. - Do not present local OIDC as proof that cloud-side UAMI or Event Grid access checks are configured correctly.
References
- OpenID Connect Discovery 1.0
- RFC 7517: JSON Web Key
- Microsoft Entra workload identity federation
- Create a federated identity credential trust
- Configure a user-assigned managed identity to trust an external identity provider
- Microsoft identity platform OIDC
- Microsoft identity platform client credentials flow
- Microsoft identity platform access tokens
- Azure Event Grid endpoint validation
- Azure Event Grid secure webhook delivery
- Azure Event Grid managed identity delivery
- ngrok webhook testing guide
josedocumentation