Skip to content

Product documentation

Conditional access policies for an application

Per-application, first-match-wins conditional-access policies: gate sign-in on authentication strength, risk level, source IP, and session age, and act with allow, deny, or step-up. Where to configure them in the console, the customer-plane API, and exactly how the hub evaluates them.

Conditional access policies for an application

A conditional-access policy decides what happens at sign-in for one OAuth application: let the user in, refuse them, or require a stronger authentication step first. The decision is made from the context of the sign-in itself — how strongly the user authenticated, the risk the platform scored, where the request came from, and how old the session is — not from a static allow-list.

Policies are configured per application and evaluated by the authorization hub on every sign-in for that application. This page covers what a policy is made of, where you set one, the API behind the console, and exactly how the hub picks a winner.

This gates access at the hub — the broker that issues tokens. It applies to every user of the application, whether they sign in with a Thoryn-hosted account or through a federated IdP (Entra ID, Okta, Google Workspace, SAML). It is a different control from identity-service's own risk-based step-up, which acts inside a Thoryn-hosted login.

How a policy is evaluated

The hub evaluates an application's policies at authorization time — after the user has authenticated and the upstream claims are known, and before the authorization code is issued — and again when a refresh token is exchanged. The algorithm is first-match-wins, fail-open:

  1. Load the application's enabled policies, ordered by priority ascending (a lower number is considered first).
  2. For each policy, test its conditions against the sign-in. A policy matches only when every condition it declares is satisfied (AND across conditions). A policy with no conditions matches every sign-in.
  3. The first policy that matches wins; its action is applied and evaluation stops.
  4. If no policy matches — or the application has no policies at all — the sign-in is allowed. This is the deliberate fail-open default: conditional access adds friction, it is not the platform's authentication floor.

Because the first match wins, order your policies from most specific to most general and put the ones that should short-circuit (a DENY) at a lower priority number.

Actions

A matched policy applies exactly one action:

ActionEffect
ALLOWThe sign-in proceeds. Use this to carve out an exception at a low priority that stops a later, broader DENY/STEP_UP from firing.
DENYThe hub refuses the sign-in and returns access_denied to the application.
STEP_UPThe sign-in must reach a higher authentication assurance — the ACR named in stepUpAcr — before access is granted; the hub asks the identity provider to step the user up. stepUpAcr is an ACR identifier your identity provider recognises (Thoryn-hosted logins use values such as urn:thoryn:acr:phishing-resistant); it is required for this action and ignored for the others.

Conditions

Every condition field is optional. An omitted field places no condition on that axis; a policy with all fields omitted always matches. The axes are:

Condition fieldValuesMatches when…
authStrengthpassword, mfa, phishing-resistantthe strength the user actually achieved (derived from the sign-in's amr) is below the level named — the session has not reached the bar. password < mfa < phishing-resistant. A sign-in with no amr counts as password (the weakest), so it is caught by any mfa or phishing-resistant bar.
minimumRiskLevelNONE, LOW, MEDIUM, HIGHthe risk the platform scored for the sign-in is at least the level named.
ipRanges + ipRangeModeCIDR strings; mode INSIDE_ALLOW or OUTSIDE_DENYINSIDE_ALLOW: the source IP is inside one of the ranges. OUTSIDE_DENY: the source IP is outside every range. Mode defaults to INSIDE_ALLOW.
maxSessionAgeSecssecondsthe session is older than this many seconds since the original authentication (auth_time).
reauthIntervalSecssecondsthe time since the original authentication exceeds this interval.
namedLocationIdsUUIDsthe source IP is inside a reusable named location's ranges (merged with any inline ipRanges, then evaluated under ipRangeMode). See the note under Named locations.

The risk level is the same signal the risk-based step-up and geo-blocking controls use; the authentication strength reflects whether the user completed multi-factor authentication.

On the auth-strength axis. authStrength is an enforcement condition, the same polarity as the risk and session-age axes: it matches when the session is below the strength you name, so the action applies to sign-ins that have not reached the bar. Pair it with DENY to refuse sign-ins that are too weak (authStrength: mfa blocks password-only), or with STEP_UP to elevate anyone below a target strength (authStrength: phishing-resistant steps up password and MFA sessions). A sign-in that already meets or exceeds the named strength does not match, so the policy leaves it alone.

Worked condition examples

Deny any sign-in the platform scored as high-risk:

{
  "name": "Block high-risk sign-ins",
  "priority": 10,
  "action": "DENY",
  "conditions": { "minimumRiskLevel": "HIGH" }
}

Require a phishing-resistant step-up for medium-or-higher risk:

{
  "name": "Step up on elevated risk",
  "priority": 20,
  "action": "STEP_UP",
  "stepUpAcr": "urn:thoryn:acr:phishing-resistant",
  "conditions": { "minimumRiskLevel": "MEDIUM" }
}

Deny sign-ins from outside your corporate ranges:

{
  "name": "Corporate network only",
  "priority": 30,
  "action": "DENY",
  "conditions": {
    "ipRanges": ["203.0.113.0/24", "198.51.100.0/24"],
    "ipRangeMode": "OUTSIDE_DENY"
  }
}

Force re-authentication once a session passes an hour:

{
  "name": "Re-auth hourly",
  "priority": 40,
  "action": "STEP_UP",
  "stepUpAcr": "urn:thoryn:acr:mfa",
  "conditions": { "maxSessionAgeSecs": 3600 }
}

Block sign-ins that did not complete multi-factor authentication:

{
  "name": "Block password-only sign-ins",
  "priority": 15,
  "action": "DENY",
  "conditions": { "authStrength": "mfa" }
}

This denies any session below MFA — a password-only login, or a sign-in whose IdP sent no amr. A session that completed MFA (or a phishing-resistant method) is at or above the bar and is allowed through.

Step up anyone who has not yet used a phishing-resistant method:

{
  "name": "Step up to phishing-resistant",
  "priority": 25,
  "action": "STEP_UP",
  "stepUpAcr": "urn:thoryn:acr:phishing-resistant",
  "conditions": { "authStrength": "phishing-resistant" }
}

Password and MFA sessions are below phishing-resistant, so they are stepped up; a session that already authenticated with a phishing-resistant method matches nothing here and proceeds.

Named locations

A policy may reference reusable named locations by id in namedLocationIds instead of repeating CIDR ranges inline. A named location is a labelled group of CIDR ranges: define "Corporate network" or "Datacenter egress" once, then reference it from any policy. When a policy lists namedLocationIds, the hub merges those locations' ranges with any inline ipRanges on the policy and evaluates them together under ipRangeMode.

Named locations have their own customer-plane surface at /api/v1/named-locations, scoped to your tenant. They ride the same application scopes as policies (see Scopes), so no extra permission is needed. Create the locations you want, then reference them by id.

MethodPathDescription
GET/api/v1/named-locationsList your tenant's named locations.
POST/api/v1/named-locationsCreate a named location. Returns 201 with its id.
GET/api/v1/named-locations/{id}Read one named location.
PATCH/api/v1/named-locations/{id}Update a named location. Omitted fields are left unchanged.
DELETE/api/v1/named-locations/{id}Delete a named location. Returns 204.

A location that is still referenced by a policy cannot be deleted: the delete returns 409 named_location_in_use. Remove the reference from every policy first, then delete the location. A malformed CIDR is rejected with 422 invalid_cidr. The full request/response schemas are in the generated NamedLocation API reference.

Create a named location, then a policy that uses it:

LOC_ID=$(curl -s -X POST https://api.thoryn.org/api/v1/named-locations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Corporate network", "cidrs": ["203.0.113.0/24", "198.51.100.0/24"]}' | jq -r .id)
 
curl -X POST https://api.thoryn.org/api/v1/clients/$CLIENT_ID/access-policies \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"name\": \"Corporate network only\", \"action\": \"DENY\",
       \"conditions\": {\"namedLocationIds\": [\"$LOC_ID\"], \"ipRangeMode\": \"OUTSIDE_DENY\"}}"

Where to configure a policy

Console (recommended). Open the application under your workspace's Applications and go to its Conditional access section. Add a policy, choose its conditions and action, and set its priority. The console reads and writes the same customer-plane API described below, scoped to the application you are editing.

API / CLI (automation). Drive the endpoints directly for scripted or repeatable configuration — see the next section.

Endpoint reference

The policies for an application live under its client id. All calls go to product-api through the api-gateway and carry a tenant admin bearer token.

MethodPathDescription
GET/api/v1/clients/{clientId}/access-policiesList the application's policies.
POST/api/v1/clients/{clientId}/access-policiesCreate a policy. Returns 201 with the stored policy (including its id).
GET/api/v1/clients/{clientId}/access-policies/{id}Read one policy.
PATCH/api/v1/clients/{clientId}/access-policies/{id}Update a policy. Omitted fields are left unchanged.
DELETE/api/v1/clients/{clientId}/access-policies/{id}Delete a policy. Returns 204.

Scopes. Conditional access is per-application configuration, so it rides the existing application scopes — there is no separate scope to grant. A read needs tenant:applications.read or tenant:clients.read; a write needs tenant:applications.write or tenant:clients.write. The console (which holds the tenant:applications.* scopes) and the thoryn CLI (which holds tenant:clients.*) are both admitted.

Create a policy:

curl -X POST https://api.thoryn.org/api/v1/clients/$CLIENT_ID/access-policies \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Block high-risk sign-ins",
    "priority": 10,
    "action": "DENY",
    "conditions": { "minimumRiskLevel": "HIGH" }
  }'

The full request/response schemas are in the generated ConditionalAccessPolicy API reference.

Troubleshooting

A policy never seems to fire. Check its priority. An earlier (lower-number) policy that matches wins and stops evaluation, so a broad ALLOW at priority 0 will mask everything after it. Confirm the policy is enabled — disabled policies are skipped entirely.

Everything is allowed even though I created a DENY. A policy matches only when every condition is satisfied. A condition that never matches (e.g. an ipRanges that never contains your test IP under INSIDE_ALLOW) means the policy as a whole never matches, and with no match the fail-open default allows the sign-in.

A session-age policy does nothing for federated users. The session-age axis needs the auth_time claim on the upstream token. A federation member that does not emit auth_time makes the condition fail-open (it cannot detect a stale session), so the policy does not fire for those users.

400 invalid_policy. The hub rejected the policy shape — an unknown action, an authStrength/minimumRiskLevel/ipRangeMode value outside the accepted set, or a malformed CIDR. The detail names the allowed values.

404 for a policy id. Either the id does not exist, or the application is not in your tenant. Cross-tenant access returns 404, never 403 — there is no existence leak, so a 404 never confirms that some other tenant's application exists.

Security notes

  • Fail-open is deliberate. No policy, or no match, allows the sign-in. Conditional access adds friction on top of authentication; it is not the authentication floor. Do not rely on it as the only barrier to a sensitive application — pair it with a strong login policy.
  • STEP_UP without stepUpAcr degrades to a deny. A STEP_UP policy that reaches evaluation with no configured ACR cannot ask for anything, so the hub denies rather than silently allowing. Always set stepUpAcr on a STEP_UP policy.
  • Source IP honours trusted proxies only. The evaluator reads the client IP from X-Forwarded-For only when the request came through a configured trusted proxy (thoryn.proxy.trusted-ips); otherwise it uses the direct peer address. An untrusted client cannot spoof its source IP through the header.
  • Tenant isolation holds. You can only address applications in your own tenant, and a cross-tenant policy id is a 404.