Continuous Identity Verification at the Application Layer
Pomerium uses JSON Web Tokens (JWTs) to help your upstream services verify a user's identity and additional context (like group membership) at the application layer. In a zero trust environment, verifying that both the client and server are who they say they are is crucial. Pomerium handles user authentication, then mints a signed JWT for every verified and authorized request.
By validating that JWT, your application or service confirms:
- The request came from Pomerium (a trusted policy enforcement point).
- The user's identity is legitimate and authorized, according to policy.
- The JWT is specifically intended for the service (not some other application).
This article explains why identity & context verification at the application layer is important, how JWT-based verification works, and four different approaches you can use to verify JWTs:
- Manually (to understand the process)
- 3rd-party app with built-in JWT support (like Grafana)
- Custom application (using an existing JWT library or Pomerium's SDK)
- Sidecar (no code changes, e.g. Envoy proxy)
JWT Authentication Flow
- User authenticates
Pomerium redirects the user to your OIDC-compliant identity provider (IdP). - Pomerium issues a signed JWT
After the user is authenticated, Pomerium mints a new JWT. - JWT assertion header
The JWT goes in theX-Pomerium-Jwt-Assertion
header, following RFC7519 encoding. - Upstream service verifies
Your application (or a helper process) confirms the JWT's signature, audience, issuer, and timestamps.
If everything checks out, your service can trust the identity data in the token for additional authorization or logging.
Why JWT-Based Verification?
- Zero Trust: Enforces that every request is from a legitimate, authenticated user.
- Application Layer: Even if TLS terminates at Pomerium, the downstream service can verify the request is valid.
- Single Sign-On: A single IdP login flows downstream. Your app can read the user's email, groups, etc., from the JWT.
- Local Validation: JWTs are stateless. After an initial login, your service doesn't need to call the IdP again; it simply verifies the token signature.
JWT Details
A Pomerium-issued JWT typically has standard fields plus additional claims:
exp
(expiration),iat
(issued-at),sub
(user ID),aud
,iss
email
(from IdP),groups
(if available),name
(if provided)
The original ID token from your IdP is never forwarded. Instead, Pomerium reissues a fresh token under its own signing key.
Verifying a JWT
Your upstream must ensure:
- Signature: The JWT was signed by Pomerium's private key.
- Audience & Issuer:
aud
andiss
match your service domain. - Expiration: The token is still valid (
exp
> now).
Fetch the Public Key (JWKS)
Your upstream can automatically fetch Pomerium's public key:
curl https://<YOUR-SERVICE-DOMAIN>/.well-known/pomerium/jwks.json \
-H 'Accept: application/json'
Pick the key matching the kid
claim in the JWT header to verify its signature.
Four Approaches to JWT Validation
1. Manual Verification
Useful for learning or debugging:
- Provide Pomerium a private key:
openssl ecparam -genkey -name prime256v1 -noout -out ec_private.pem
openssl ec -in ec_private.pem -pubout -out ec_public.pem
cat ec_private.pem | base64 # copy to SIGNING_KEY in Pomerium config - Inspect the header (
X-Pomerium-Jwt-Assertion
) in a request after you've logged in. - Decode the token on a site like jwt.io or a local JWT decoder library.
- Paste your
ec_public.pem
into the decoder's “verify signature” field. If it's valid, the user claims are genuine.
If you use httpbin
to inspect headers, you might see:
Decoded, you'll see the claims:
After adding your public key, you should see a verified signature:
2. 3rd-Party App with Built-In JWT Support
Many modern platforms (for example, Grafana) allow you to configure JWT-based SSO. Once you enable JWT authentication in Grafana (and point it to Pomerium's JWKS endpoint), all inbound requests with a valid X-Pomerium-Jwt-Assertion
token are accepted for user identity. Grafana (or your chosen app) sees the user's email, groups, etc., and can apply its own RBAC logic.
For a real-world example, see our Grafana guide.
3. Custom Application (JWT Libraries or Pomerium SDK)
If you're building a custom in-house app, you can parse and validate the JWT using a standard library or one of Pomerium's SDKs:
Go Example
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-jose/go-jose/v3/jwt"
"github.com/pomerium/sdk-go"
)
func main() {
verifier, err := sdk.New(&sdk.Options{
Expected: &jwt.Expected{
// Replace the following with the domain for your service:
Issuer: "sdk-example.localhost.pomerium.io",
Audience: jwt.Audience([]string{
"sdk-example.localhost.pomerium.io"}),
},
})
if err != nil {
log.Fatalln(err)
}
http.Handle("/", sdk.AddIdentityToRequest(verifier)(handler{}))
log.Fatalln(http.ListenAndServe(":8080", nil))
}
type handler struct{}
func (handler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
// Check the JWT verification result.
id, err := sdk.FromContext(req.Context())
if err != nil {
fmt.Fprintln(res, "verification error:", err)
return
}
fmt.Fprintf(res, "verified user identity (email %s)\n", id.Email)
}
Node/JS Example
const express = require("express");
const { PomeriumVerifier } = require('@pomerium/js-sdk');
const app = express();
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; //just for dev
app.get("/tofu", (request, response) => {
const jwtVerifier = new PomeriumVerifier({});
jwtVerifier.verifyJwt(request.get('X-Pomerium-Jwt-Assertion')).then(r => response.send(r))
});
app.get("/wrong-audience", (request, response) => {
const jwtVerifier = new PomeriumVerifier({
audience: [
'correct-audience.com'
],
expirationBuffer: 1000
});
jwtVerifier.verifyJwt(request.get('X-Pomerium-Jwt-Assertion'))
.then(r => response.send(r))
.catch(e => response.send(e.message));
});
app.get("/wrong-issuer", (request, response) => {
const jwtVerifier = new PomeriumVerifier({
issuer: 'correct-issuer.com',
expirationBuffer: 1000
});
jwtVerifier.verifyJwt(request.get('X-Pomerium-Jwt-Assertion'))
.then(r => response.send(r))
.catch(e => response.send(e.message));
});
app.get("/expired", (request, response) => {
const jwtVerifier = new PomeriumVerifier({
expirationBuffer: -10000
});
jwtVerifier.verifyJwt(request.get('X-Pomerium-Jwt-Assertion'))
.then(r => response.send(r))
.catch(e => response.send(e.message));
});
app.listen(3010, () => {
console.log("Listen on the port 3010...");
});
Your application:
- Extracts the token from the
X-Pomerium-Jwt-Assertion
header. - Uses a JWT library (or Pomerium's SDK) to verify the signature via the JWKS URL.
- Confirms
aud
,iss
, andexp
. - Trusts the user claims (like
email
,groups
, etc.) if valid.
Tip: In Pomerium's JS SDK, if you don't specify issuer
and audience
, it applies trust-on-first-use (TOFU) logic. We recommend explicitly setting these in production.
4. Sidecar (Envoy)
If you can't modify your app's code, run a Envoy sidecar to check JWTs before requests reach the app. Envoy's JWT Authn filter automatically retrieves Pomerium's JWKS and enforces valid tokens.
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
pomerium:
issuer: myapp.localhost.pomerium.io
audiences:
- myapp.localhost.pomerium.io
from_headers:
- name: X-Pomerium-Jwt-Assertion
remote_jwks:
http_uri:
uri: https://myapp.localhost.pomerium.io/.well-known/pomerium/jwks.json
cluster: egress-authenticate
timeout: 1s
rules:
- match:
prefix: /
requires:
provider_name: pomerium
With a sidecar, your main application doesn't need to be JWT-aware. Envoy rejects bad tokens and only forwards valid requests.
Conclusion
JWT-based identity & context verification lets your upstream service confirm each request is coming from a trusted policy enforcement point (Pomerium) and that the user's identity is valid. You can choose whichever approach fits best:
- Manual for debugging or demonstration.
- 3rd-Party app if it already supports JWT.
- Custom app with libraries or Pomerium's SDK.
- Sidecar if you can't modify your code.
Regardless of approach, the result is the same: a zero trust environment where your application is confident every incoming request is from a legitimate user, with valid identity claims. Once verified, you can apply your own additional rules (RBAC, logging, or anything else that uses user context) without re-checking the IdP.