Skip to content

Secure your API

EntryIdP issues standard RS256 JWT access tokens that any resource server can verify locally — no server-to-server call, no extra infrastructure. But securing an API is two jobs, and EntryIdP only does one of them:

  • Authenticationwho is this user, and are they really here? EntryIdP answers this. Your API validates the token.
  • Authorizationmay this user perform this action on this resource? Only your API can answer this, from your own data.

Getting these two confused is the single most common integration mistake. This page covers both: how to validate an EntryIdP token (authentication), and how to build the authorization layer on top of it — with a worked example.

Authentication vs authorization

They answer different questions, and they are owned by different systems.

Question it answersWho answers itHow
AuthenticationWho is this user? Did a real human really pass liveness?EntryIdPYour API validates the access token's signature and claims.
AuthorizationMay this user do this action on this resource?Your APIYour API checks the user against your own data and rules.

The rule: the token tells you who the user is and that they're really present. What they may do, and on which resource, is a question only your API can answer — from your own data.

What the token proves — and what it can't

You can trust:

  • A real human passed face liveness. The signature is the biometric assertion — see The biometric guarantee below.
  • Who the user is — a stable sub, unique per person and identical across every login. This is your join key into your own data.
  • Which tenant the user belongs to (tenant_id) and their coarse, organisation-level roles (roles) — see Can I put roles on the token?

You must not expect — the token never carries any of this:

  • Whether the user owns or belongs to a given resource (a policy, an order, a document).
  • Any "acting on behalf of" relationship (an agent for a client, a parent for a child).
  • Per-record permissions, entitlements, or your business rules.
  • Anything about your application domain at all — EntryIdP has never heard of your policies.

Claims on the access token

ClaimTypeNotes
substringUser identifier. Stable across sessions, unique per person. Use this as your key.
issstringIssuer. Validate it equals your EntryIdP environment URL.
audstringThe client_id the token was issued to. Validate it matches your app — see The aud claim.
expnumberExpiry (Unix seconds). Reject expired tokens.
iatnumberIssued-at (Unix seconds).
jtistringToken ID. Use for replay detection / a denylist if you need one.
scopestringSpace-separated granted scopes (e.g. openid profile roles).
tenant_idstringTenant the user belongs to. Absent if the user isn't enrolled in the client's tenant.
rolesstring[]Coarse, org-level roles in that tenant — operator-assigned. Empty if none. See Can I put roles on the token?

face_liveness_verified, acr, amr, and liveness_confidence_score live in the ID token, which is held by the frontend. Your API receives only the access token and doesn't need those claims to make authorization decisions.

Token header quirks:

  • Access tokens carry "typ": "at+jwt" (the RFC 9068 JWT access-token profile), not a bare "JWT". Some libraries assume "JWT" and will reject a structurally valid token on typ alone — check your validation library isn't doing this.
  • kid in the token header (and in the JWKS response) is the full AWS KMS key ARN (e.g. arn:aws:kms:eu-west-2:<account>:key/<uuid>) — colons, slashes, and all — not a short opaque ID. It's stable per signing key and only changes on key rotation, but don't write custom key-lookup logic that assumes a short kid format.

Need the full ID-token/userinfo claim list, the scope-to-claim mapping, or the refresh-token grant? See the Reference — this page only covers what your API's access-token validation needs.

The biometric guarantee

EntryIdP disables client_credentials — there is no way to issue a token without a human passing a face liveness check. This means:

If the token signature validates against the EntryIdP JWKS, a real user passed face liveness to obtain it.

Your API doesn't need to check biometric claims individually. The signature on the token is the biometric assertion.

Validate the token — the authentication half

Every protected request starts here. Your frontend (SPA, mobile app, or server) authenticates with EntryIdP and receives an access token. That token travels as a Bearer header to your API. Your API validates the signature using EntryIdP's public JWKS, then reads the claims.

Want to see a real token first? The interactive sample app runs this exact Authorization Code + PKCE login and shows you the authorization code, the decoded access and ID tokens, and the /userinfo response at each step — so you can see what the request and responses actually look like before writing any code. (Seeded in non-production environments only.)

Frontend / Mobile app
  → EntryIdP      Authorization Code + PKCE
  ← access_token  RS256 JWT (15 min)

Frontend → Your API   Authorization: Bearer <access_token>
Your API:
  1. GET /.well-known/jwks.json  (fetch once, cache — keys rarely rotate)
  2. Verify: signature + iss + aud + exp
  3. Read sub  → now you know WHO. Authorization comes next.
  → 200 OK

The aud claim

The access token's aud (audience) is the client_id that the frontend used when obtaining the token. Your API must validate that aud matches.

  • Server-rendered apps — your frontend and API are the same registered client. aud is your own client_id.
  • Separate frontend and API — your API must know which client_id your frontend is registered under, and configure that as the expected audience.

.NET

Install the JWT Bearer package:

bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Wire up authentication in Program.cs:

csharp
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://idp.entryidp.com"; // your EntryIdP environment URL
        options.Audience  = "your-client-id";           // the client_id your frontend used
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidAlgorithms = new[] { "RS256" },
        };
    });

app.UseAuthentication();
app.UseAuthorization();

AddJwtBearer fetches and caches the JWKS automatically via the Authority discovery URL. No manual JWKS wiring required. Now User.FindFirstValue("sub") gives you the authenticated caller.

Common setup pitfalls

Both of these fail with an exception nowhere near the actual cause — worth checking first if tokens aren't validating.

A pinned transitive package can silently empty the JWKS. Microsoft.AspNetCore.Authentication.JwtBearer 8.0.11 pulls in Microsoft.IdentityModel.Protocols.OpenIdConnect 7.1.2. If anything else in your solution forces Microsoft.IdentityModel.Tokens / JsonWebTokens / Abstractions onto a newer 8.x line (easy to hit via System.IdentityModel.Tokens.Jwt 8.3.1+), Protocols.OpenIdConnect stays on 7.1.2 and its discovery-document parser silently returns an empty jwks_uri — no exception at startup, no exception on fetch, just zero signing keys until a real request fails with:

IDX10500: Signature validation failed. No security keys were provided to validate the signature.

Fix: keep every Microsoft.IdentityModel.* package on the same major/minor line as Microsoft.AspNetCore.Authentication.JwtBearer. Run dotnet list package --include-transitive and check for a split Microsoft.IdentityModel.Protocols* version before shipping.

Missing app.UseAuthentication() throws a raw 500, not a 401. It's easy to have [Authorize] and app.UseAuthorization() wired up — a normal starting point when adding auth to an existing API — and still be missing app.UseAuthentication() in the pipeline. ASP.NET Core doesn't guard against this combination; every protected endpoint throws, regardless of whether a token is even present:

System.InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found.

Fix: app.UseAuthentication() must come before app.UseAuthorization(), as shown above.

JWKS is fetched lazily, on the first real request. AddJwtBearer's ConfigurationManager fetches discovery metadata on first use, not at startup — combined with the silent-failure mode above, a broken deploy can look healthy until the first real user hits it. Consider forcing a refresh at startup or in a health check:

csharp
var jwtOptions = app.Services.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
    .Get(JwtBearerDefaults.AuthenticationScheme);
await jwtOptions.ConfigurationManager!.GetConfigurationAsync(default);

Node.js

Install jose:

bash
npm install jose

Create a shared JWKS client. createRemoteJWKSet fetches and caches the keys — call it once at startup, not per request:

js
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://idp.entryidp.com/.well-known/jwks.json')
);

export async function verifyToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer:     'https://idp.entryidp.com', // your EntryIdP environment URL
    audience:   'your-client-id',           // the client_id your frontend used
    algorithms: ['RS256'],
  });
  return payload; // payload.sub is the authenticated caller
}

Express middleware that puts the validated identity on req.user:

js
export async function requireAuth(req, res, next) {
  const header = req.headers.authorization ?? '';
  if (!header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  try {
    req.user = await verifyToken(header.slice(7)); // req.user.sub is now trusted
    next();
  } catch {
    res.status(401).json({ error: 'invalid_token' });
  }
}

At this point you know who the caller is. That is authentication. Everything below is authorization — and it is yours.

Test your integration without the live IdP

For integration tests you don't want to hit idp-test.entryidp.com for a token on every run. Generate a throwaway RSA key pair in your test setup, mint a JWT with it locally using the claims from the claims table above, and point your validation config at that key directly instead of the real Authority/JWKS.

.NET

csharp
// Test fixture setup — a throwaway key, never the real EntryIdP signing key.
var rsa = RSA.Create(2048);
var signingKey = new RsaSecurityKey(rsa) { KeyId = "test-key" };

var handler = new JwtSecurityTokenHandler();
var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor
{
    Issuer = "https://idp-test.entryidp.com",
    Audience = "your-client-id",
    Claims = new Dictionary<string, object>
    {
        ["sub"] = "test-user-sub",
        ["tenant_id"] = "test-tenant",
        ["roles"] = new[] { "admin" },
    },
    Expires = DateTime.UtcNow.AddMinutes(15),
    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256),
});

// In your WebApplicationFactory / test host — bypass Authority/JWKS discovery entirely.
builder.ConfigureTestServices(services =>
{
    services.PostConfigure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.Authority = null;
        options.TokenValidationParameters.ValidIssuer = "https://idp-test.entryidp.com";
        options.TokenValidationParameters.ValidAudience = "your-client-id";
        options.TokenValidationParameters.IssuerSigningKey = signingKey;
    });
});

Node.js

js
import { generateKeyPair, SignJWT, exportJWK } from 'jose';

// Test fixture setup — a throwaway key, never the real EntryIdP signing key.
const { privateKey, publicKey } = await generateKeyPair('RS256');

const token = await new SignJWT({ tenant_id: 'test-tenant', roles: ['admin'] })
  .setProtectedHeader({ alg: 'RS256', typ: 'at+jwt' })
  .setSubject('test-user-sub')
  .setIssuer('https://idp-test.entryidp.com')
  .setAudience('your-client-id')
  .setExpirationTime('15m')
  .sign(privateKey);

// Verify directly against the public key — no network JWKS fetch in tests.
import { jwtVerify } from 'jose';
const { payload } = await jwtVerify(token, publicKey, {
  issuer: 'https://idp-test.entryidp.com',
  audience: 'your-client-id',
});

Authorize the request — a worked example

Consider an insurer with three users, all in the same tenant:

  • User 1 — a policy holder who creates a policy for themselves.
  • User 2 — a dependant on that policy, who can initiate a claim on a policy where they are a dependant.
  • User 3 — an agent, who can create a claim on behalf of User 2.

All three authenticate identically. Each passes a face liveness check and receives a token whose sub you can trust. The token says nothing about policies, who is a dependant, or who is an agent — because EntryIdP doesn't know your business. That is exactly the point: authorization is your job, and you already have the data for it.

Your database owns the relationships

Everything the token can't tell you lives in your own tables, keyed on the token's sub:

sql
-- Who is an agent is YOUR fact, set during your own onboarding — not the token's.
app_users(sub PK, is_agent BOOLEAN)

policies(id PK, holder_sub)

-- One row per person on a policy. relationship ∈ ('holder', 'dependant').
policy_members(policy_id, sub, relationship)

-- Which subjects an agent is permitted to act for.
agent_clients(agent_sub, client_sub)

Each endpoint does the same two steps: authenticate (validate the token → sub, already done by the middleware above) then authorize (query your data for the domain facts).

Node.js

js
// User 1 — create a policy for yourself.
// Any authenticated user may do this; they become the holder.
app.post('/api/policies', requireAuth, async (req, res) => {
  const holderSub = req.user.sub;                    // the authenticated caller — never from the body
  const policy = await db.createPolicy({ holderSub });
  await db.addPolicyMember(policy.id, holderSub, 'holder');
  res.status(201).json(policy);
});

// User 2 — initiate a claim on a policy you belong to.
// Allowed only if YOUR data shows this sub is a member (holder or dependant) of the policy.
app.post('/api/policies/:policyId/claims', requireAuth, async (req, res) => {
  const sub = req.user.sub;
  const membership = await db.getPolicyMember(req.params.policyId, sub);
  if (!membership) return res.status(403).json({ error: 'not_a_member' }); // the token can't tell you this
  const claim = await db.createClaim({ policyId: req.params.policyId, initiatedBy: sub, forSub: sub });
  res.status(201).json(claim);
});

// User 3 — an agent creates a claim ON BEHALF OF another member.
// Three checks, all against YOUR data.
app.post('/api/policies/:policyId/claims/on-behalf-of/:memberSub', requireAuth, async (req, res) => {
  const agentSub = req.user.sub;
  const { policyId, memberSub } = req.params;

  const caller = await db.getAppUser(agentSub);
  if (!caller?.is_agent) return res.status(403).json({ error: 'not_an_agent' });            // 1. is an agent?
  if (!await db.isAgentForClient(agentSub, memberSub))                                       // 2. may act for them?
    return res.status(403).json({ error: 'not_your_client' });
  if (!await db.getPolicyMember(policyId, memberSub))                                        // 3. member on policy?
    return res.status(403).json({ error: 'member_not_on_policy' });

  const claim = await db.createClaim({ policyId, initiatedBy: agentSub, forSub: memberSub });
  res.status(201).json(claim);
});

.NET

csharp
// The authenticated caller — set by JwtBearer from the validated token, never from the request body.
private string CallerSub => User.FindFirstValue("sub")!;

// User 1 — create a policy for yourself.
[Authorize]
[HttpPost("/api/policies")]
public async Task<IActionResult> CreatePolicy()
{
    var policy = await _db.CreatePolicyAsync(holderSub: CallerSub);
    await _db.AddPolicyMemberAsync(policy.Id, CallerSub, "holder");
    return Created($"/api/policies/{policy.Id}", policy);
}

// User 2 — initiate a claim on a policy you belong to.
[Authorize]
[HttpPost("/api/policies/{policyId}/claims")]
public async Task<IActionResult> CreateClaim(string policyId)
{
    var membership = await _db.GetPolicyMemberAsync(policyId, CallerSub);
    if (membership is null) return Forbid();          // not a holder or dependant — the token can't tell you
    var claim = await _db.CreateClaimAsync(policyId, initiatedBy: CallerSub, forSub: CallerSub);
    return Created($"/api/claims/{claim.Id}", claim);
}

// User 3 — an agent creates a claim on behalf of a member.
[Authorize]
[HttpPost("/api/policies/{policyId}/claims/on-behalf-of/{memberSub}")]
public async Task<IActionResult> CreateClaimFor(string policyId, string memberSub)
{
    var caller = await _db.GetAppUserAsync(CallerSub);
    if (caller is not { IsAgent: true }) return Forbid();                          // 1. is an agent?
    if (!await _db.IsAgentForClientAsync(CallerSub, memberSub)) return Forbid();    // 2. may act for them?
    if (await _db.GetPolicyMemberAsync(policyId, memberSub) is null) return Forbid(); // 3. member on policy?

    var claim = await _db.CreateClaimAsync(policyId, initiatedBy: CallerSub, forSub: memberSub);
    return Created($"/api/claims/{claim.Id}", claim);
}

Never trust a resource id, an owner id, or an "on behalf of" subject sent by the client. Take the caller's identity only from the validated token's sub, then resolve every relationship server-side from your own data. A malicious client can put any memberSub in a URL — your agent_clients check is what stops it.

Can I put roles on the token?

Yes — EntryIdP has a per-tenant roles claim (arbitrary strings; a client_id maps to exactly one tenant, so a role in one tenant never leaks into another). It's the right home for a coarse, organisation-wide, slow-changing role that gates access before you'd otherwise hit your database — for example an admin role guarding a back-office console.

For the insurance example we deliberately kept everything — including "is an agent" — in the application database. Two reasons:

  1. Freshness & source of truth. A roles claim is only as fresh as the token (up to the ~15-minute access-token lifetime), and it splits your authorization data across two systems. Your database is instant and is the single source of truth. Since you're already querying it for the relationship check (is this user a dependant on that policy?), checking is this user an agent? in the same place costs essentially nothing.
  2. Who assigns the role. Today, role assignment is operator-mediated — roles are set via an EntryIdP operator endpoint (PUT /admin/users/{sub}/roles), not by a tenant's own admin. There is no tenant self-service role management yet (it's on the roadmap). Until it ships, putting agent on the token means an EntryIdP operator has to assign it for every agent — a bottleneck you avoid entirely by owning the flag in your own data.

Recommendation: default to DB-owned authorization keyed on sub. Reach for the roles claim only for a genuine org-wide gate you're happy to have an operator assign. If you need your own admins to manage roles yourselves, get in touch — tenant self-service role management is on the roadmap.

Registering your client

To issue tokens your API can validate, you need a registered EntryIdP client for your frontend app. See Get a client.

Your API itself does not need a client registration — it only validates tokens, never initiates an auth flow.

Environments

Use the same issuer for both your frontend OIDC configuration and your API's Authority / issuer validation parameter.

EnvironmentIssuer URL
Testhttps://idp-test.entryidp.com
Demohttps://idp-demo.entryidp.com
Productionhttps://idp.entryidp.com

Need exact endpoint shapes, the full scope/claim list, or error meanings? See the Reference.

EntryIdP — Synapser