Skip to content

Post-render Scripts

Overview

Post-render scripts let you run a short Go program after claim mappings (OIDC) or attribute mappings (SAML) have been evaluated. The script receives the current claims/attributes and can:

  • Patch them — add new claims or override existing ones
  • Delete a claim — return it with an empty string value ("")
  • Deny access — return a non-nil error (unless script_fail_open is enabled)
  • Query the IdP — look up users, groups, or attributes via the read-only idp package
  • Call external APIs — use net/http for external policy engines

Scripts run in a sandboxed Yaegi interpreter with a restricted standard library and a configurable timeout (1–10 s, default 3 s).


Script contract

Scripts must declare package claim and export a single entry point:

package claim

func Evaluate() (map[string]interface{}, error)
  • The returned map patches the existing claims. Each key/value pair is merged into the outgoing token:
    • A non-empty value adds or replaces the claim.
    • An empty string value ("") deletes the claim from the token.
    • Returning nil or an empty map leaves all claims unchanged.
  • A non-nil error denies the login. The error message is displayed to the user on a branded error page (HTML) and is also recorded in the audit log.

Configuration

Field Default Description
claim_script (OIDC) / attribute_script (SAML) "" Go source code for the script. Empty string disables the hook.
script_timeout_secs 3 Maximum execution time in seconds. Clamped to 1–10.
script_fail_open false When true, a script error or timeout does not block login — the unmodified claims are used instead.

Required permissions

Access to post-render scripts is controlled by two granular permissions that can be assigned via Admin Roles independently from general application management:

Permission Effect
apps.scripts.view The Post-render Script section is visible in read-only mode. Requires apps.view.
apps.scripts.update Can write, modify, or clear scripts. Requires apps.update.

Users who hold apps.update but neither script permission can still edit all other application settings. The script section is hidden from their view and the existing script is silently preserved on every save — they cannot accidentally clear a script they cannot see.


Adding a script

UI: Administration → Applications → (select app) → Post-render Script section

API:

PUT /api/v1/applications/{id}
{
  "claim_script": "package claim\nfunc Evaluate() (map[string]interface{}, error) {\n  return map[string]interface{}{\"role\": \"viewer\"}, nil\n}",
  "script_timeout_secs": 5,
  "script_fail_open": false
}

For SAML:

PUT /api/v1/saml/{app_id}/config
{
  "attribute_script": "...",
  "script_timeout_secs": 5,
  "script_fail_open": false
}

Injected packages

secrets — Encrypted script secrets

import "secrets"

apiKey := secrets.Get("API_KEY")  // returns decrypted value or "" if not set

Secrets are encrypted at rest (AES-256-GCM). Manage them in the application settings panel under Script Secrets, or include a script_secrets map in the application create/update API request. Secret values are never returned by the API — only key names are exposed.

request — HTTP context at login time

import "request"

referer := request.Referer()  // HTTP Referer at /authorize (OIDC) or SSO endpoint (SAML)
loginIP := request.LoginIP()  // Client IP at /authorize or SSO time
tokenIP := request.TokenIP()  // Always empty — script runs at /authorize time for OIDC; reserved for future use

user — Authenticated user

import "user"

id       := user.ID()
email    := user.Email()
username := user.Username()
fn       := user.FirstName()
ln       := user.LastName()
groups   := user.Groups()      // []string
attrs    := user.Attributes()  // map[string]string
mappings := user.AppMappings() // map[string][]string

claims — Read-only view of existing claims/attributes

import "claims"

val    := claims.Get("sub")           // interface{}
str    := claims.GetString("email")   // string
list   := claims.GetList("groups")    // []string
ok     := claims.Has("custom_claim")  // bool
all    := claims.All()                // map[string]interface{}

idp — Read-only IdP queries

import "idp"

u, err  := idp.GetUserByEmail("alice@example.com")  // *idp.User
g, err  := idp.GetGroupByName("admins")             // *idp.Group
members, err := idp.GetGroupMembers(g.ID)           // []idp.User
attrs, err   := idp.GetUserAttributes(u.ID)         // map[string]string

Allowed standard library packages

Scripts may import the following packages. All other packages (including os, os/exec, syscall, unsafe, runtime, reflect, plugin) are blocked.

Package Notes
secrets Injected — access application script secrets
bytes
encoding/json
errors
fmt
io
math, math/rand
net/http Outbound HTTP — be aware of SSRF risk
net/url
regexp
sort
strconv
strings
time
unicode, unicode/utf8

Example scripts

Add a static role

package claim

func Evaluate() (map[string]interface{}, error) {
    return map[string]interface{}{"role": "viewer"}, nil
}

Deny login outside business hours

package claim

import (
    "fmt"
    "time"
)

func Evaluate() (map[string]interface{}, error) {
    h := time.Now().UTC().Hour()
    if h < 8 || h >= 18 {
        return nil, fmt.Errorf("logins only permitted 08:00–18:00 UTC")
    }
    return nil, nil
}

Remove a claim based on the referrer

package claim

import (
    "claims"
    "request"
    "strings"
)

func Evaluate() (map[string]interface{}, error) {
    if !strings.HasPrefix(request.Referer(), "https://internal.corp/") {
        // strip sensitive claim for external logins
        return map[string]interface{}{"internal_id": ""}, nil
    }
    return nil, nil
}

Enrich with an external policy engine

package claim

import (
    "encoding/json"
    "fmt"
    "net/http"
    "user"
)

func Evaluate() (map[string]interface{}, error) {
    resp, err := http.Get("https://policy.internal/roles?email=" + user.Email())
    if err != nil {
        return nil, fmt.Errorf("policy check failed: %w", err)
    }
    defer resp.Body.Close()
    var result struct{ Role string }
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return map[string]interface{}{"role": result.Role}, nil
}

script_fail_open

When script_fail_open is false (the default), any error — compile error, runtime panic, timeout, or an explicit error returned from Evaluate — blocks the login.

When script_fail_open is true, errors are logged and the unmodified claims are passed through. Use this for non-critical enrichment where availability matters more than correctness.


Terraform

resource "justiam_application" "my_app" {
  name = "my-app"
  type = "oidc"
  # ... other fields ...

  claim_script       = file("${path.module}/scripts/claim_enrich.go")
  script_timeout_secs = 5
  script_fail_open   = false
}

For SAML:

resource "justiam_saml_config" "my_saml" {
  app_id = justiam_application.my_saml_app.id
  # ... other fields ...

  attribute_script    = file("${path.module}/scripts/attr_enrich.go")
  script_timeout_secs = 5
  script_fail_open    = false
}

OIDC vs SAML differences

OIDC SAML
Script field claim_script attribute_script
request.TokenIP() Available (IP at /token time) Always empty
Applied to ID token + userinfo claims Assertion attributes
Hook runs when /authorize (patch stored in auth code, applied at /token//userinfo) All 3 assertion paths (SP-initiated, IdP-initiated, metadata)

More examples

See Script Examples for additional post-render scripts:

  • Inject user attributes — copy custom profile fields into token claims
  • Group-based roles — map group memberships to a roles array claim
  • Deny outside business hours — restrict app access by time of day
  • Enrich from external API — fetch metadata from an HR system at login
  • IP allowlist — deny login from untrusted networks