AppGuard

Documentation

Getting AppGuard into an app is four steps and about ten lines. This page covers setup, the policy model, handling events, and what to check when something does not work.

Requirements

minSdk30
compileSdk37 or newer
Android Gradle Plugin9.1.0 or newer
GradleThe version your AGP requires, running on JDK 17 or newer
Kotlin2.2 or newer (AGP 9's built-in Kotlin qualifies)
ABIsarm64-v8a, armeabi-v7a, x86, x86_64

The native detection layer ships prebuilt inside the AAR for all four ABIs, so you do not need the NDK or CMake. Do not exclude libagcore.so through packaging options: on a supported ABI, a missing native layer is treated as tampering and the app exits at startup.

Add the plugin

Both the plugin and the library AAR resolve from Maven Central — no extra repository, no credentials, no collaborator access to request. The licence key you add in the next step is the real gate; the distribution channel itself is public, the same as any other Gradle dependency.

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("pro.paphitis.appguard") version "0.1.9"
}

dependencies {
    implementation("pro.paphitis.appguard:android-app-guard:0.1.9")
}

The plugin and the AAR carry the same version, and must stay in step. The plugin writes check names into BuildConfig that the library parses back, and the library rejects a name it does not recognise rather than silently skipping the check.

The plugin must be applied to an application module. It configures BuildConfig on the APK that ships, so applying it to a library or JVM module fails the build with an explanatory message.

Set it up with an AI agent

Using Claude Code, Cursor, Copilot or another coding agent? AppGuard publishes a setup skill that takes the agent through everything on this page: dependencies, licence key, policy block, SecureApplication, activities and manifest. For anything only you can supply or decide, it asks you instead of guessing.

Claude Code: install the skill into your project, then ask Claude to “set up AppGuard”:

mkdir -p .claude/skills/appguard-setup && cd .claude/skills/appguard-setup && \
curl -fsSL --remote-name-all \
  https://appguard.paphitis.pro/skills/appguard-setup/SKILL.md \
  https://appguard.paphitis.pro/skills/appguard-setup/reference.md \
  https://appguard.paphitis.pro/skills/appguard-setup/troubleshooting.md

To make it available in every project, install it into ~/.claude/skills/ instead.

Any other agent: if it can read web pages, paste this prompt:

Set up the AppGuard Android SDK in this project. Follow
https://appguard.paphitis.pro/skills/appguard-setup/SKILL.md exactly, including the
reference.md and troubleshooting.md next to it, and ask me whenever it says to ask.

It will ask you for:

It finishes with a report: what it changed, what's still yours to do before a release, and which defaults it chose for you to confirm.

Review what the agent changes. The rest of this page is exactly what the skill applies, so you can use it as a checklist. In particular, a release build with no certificate fingerprints crashes on launch. The report flags that, but only you can supply them. You can read the skill itself at SKILL.md.

Add your licence key

A licence key names one base application ID, the environment suffixes it covers, and an expiry. The plugin verifies it during configuration — offline, with no call home — and fails the build if the variant's application ID is not covered. Request one here.

TierForTermPrice
DevelopmentEvaluation, non-production builds3 monthsFree
StartupProduction, under 10,000 users12 months€100 / year
EnterpriseProduction, 10,000 users or more12 months€1,000 / year

Per application ID; VAT is calculated and added by Stripe at checkout where applicable. There is no grace period — the plugin verifies offline, so an expired licence fails the build the moment it lapses. Renew before the expiry date in your key.

The plugin looks in four places, first match wins:

1. Build scriptappGuard { licenseKey = "AG1.…" }
2. Gradle propertyappguard.licenseKey=AG1.…
3. EnvironmentAPPGUARD_LICENSE_KEY=AG1.…
4. Fileappguard.license in the module or root project directory
Prefer option 2 or 3. A licence key identifies your organisation, and build.gradle.kts is usually committed. Put it in ~/.gradle/gradle.properties for developers and in a secret for CI, and it never reaches version control.

Which environments a licence covers:

Licensed base ID : com.example.app
Suffixes         : (production), .qa, .uat

Builds that pass : com.example.app
                   com.example.app.qa
                   com.example.app.uat

Builds that fail : com.example.app.staging   ← a valid suffix, but not on this licence

Suffixes are not limited to a fixed list — ask for whatever your pipeline uses, .staging, .internal, .beta. What matters is that the one you build with is on the licence you were issued.

The check runs per variant against the resolved application ID, so product flavours and applicationIdSuffix are both accounted for. Request every environment you ship to — adding one later means a new key.

Choose a policy per check

Each check is independently ALLOWED, WARNING or ENFORCED, set per build type. Both blocks are optional; anything you leave out takes the default in the reference table below.

// app/build.gradle.kts
import pro.paphitis.appguard.gradle.SecurityPolicy

appGuard {
    licenseKey = "AG1.…"
    allowedSigningCertificates = listOf("A1:B2:…")

    debug {
        // Normal on a development machine — running these would mean a
        // finding on every single launch.
        signature = SecurityPolicy.ALLOWED
        installer = SecurityPolicy.ALLOWED
        debugger  = SecurityPolicy.ALLOWED
        emulator  = SecurityPolicy.ALLOWED

        // Something genuinely wrong with the device, not the build.
        root      = SecurityPolicy.WARNING
        hooking   = SecurityPolicy.WARNING

        // FLAG_SECURE blocks screenshots — usually unwanted while developing.
        secureWindow = false
    }

    release {
        signature = SecurityPolicy.ENFORCED
        debugger  = SecurityPolicy.ENFORCED
        emulator  = SecurityPolicy.ENFORCED
        root      = SecurityPolicy.ENFORCED
        hooking   = SecurityPolicy.ENFORCED
        installer = SecurityPolicy.ALLOWED   // opt in when you ship store-only
    }
}
An ENFORCED release build terminates on a finding. That is the point, but it makes release awkward for interactive testing. Test on debug, or drop the specific check to WARNING while you investigate.

Build types other than debug use the release block. A build type nobody configured is more likely to ship than to be a development build, and guessing wrong in that direction is the expensive mistake.

Root, hooking and native-debugger checks also re-run while the app is in the foreground, not just at startup — set alongside the checks above:

appGuard {
    release { runtimeCheckIntervalSeconds = 60 }
}

Defaults to 30 seconds in both build types, and it is a target rather than an exact period: the actual delay is randomised ±50% around it each time — 30 seconds means the next check lands somewhere between 15 and 45 — so there is no predictable quiet period for an attacker to time a patch against. Must be positive; the build fails at configuration time otherwise, same as any other misconfigured policy.

Signed policy config (optional)

Everything on this page so far reaches the app through BuildConfig — plain constants in the compiled DEX, readable and editable with jadx or apktool in about a minute. Signed policy config doesn't hide that value — nothing shipped to a device is truly hidden from a determined attacker — it makes editing it pointless instead: the value is cryptographically signed, and the library verifies the signature before trusting it, so a patched policy is rejected — and the app terminated — rather than silently used.

New, and self-serve. You can sign a config for your current appGuard { } block from the Sign a config for this licence link in your licence email. That link is specific to your licence, so there is no page here to navigate to — admins can reach the signing page by signing in first. Nothing about this feature is required: skip this section entirely and AppGuard behaves exactly as described everywhere else on this page.

What you get signed is the same information already in your appGuard { } block — the six checks, your certificate whitelist, the two window protections, and the runtime re-check interval — for one build type at a time, since debug and release routinely differ. Three steps, all required — the first two alone do nothing.

1. Save the file next to appguard.license, named for the build type it covers — same rule as the licence file: the module directory (the one with id("pro.paphitis.appguard") applied) or the root project directory, either works:

my-app/
  appguard.license
  appguard-config-debug.sig
  appguard-config-release.sig

Both files are safe to commit. Signed config is not secret — it can be read, but not forged or edited, the same reasoning that already applies to the licence key.

2. Add the override in SecureApplication, next to the overrides you already have — the plugin writes the file's contents into BuildConfig automatically, but nothing reads that field until you add this line yourself:

class MyApp : SecureApplication() {
    override val securityPolicies = SecurityPolicies.parse(BuildConfig.APPGUARD_POLICIES)
    override val signedPolicyConfig = BuildConfig.APPGUARD_SIGNED_POLICY_CONFIG
    // …
}

3. Rebuild — everything else is automatic from here. At every build, the plugin recomputes the same information from your current appGuard { } block and compares it, byte for byte, against what was signed. If they no longer match — you changed a policy and forgot to have it re-signed — a release build fails before anything compiles, with a message telling you which file is now stale. A debug build only warns and carries on: it leaves the signed config out, so the app runs your debug { } values as written while you iterate. Get the file re-signed before you build a release.

At runtime, the library verifies the signature (Ed25519, via Bouncy Castle) before trusting anything in it. A config that verifies takes precedence over the plain BuildConfig values automatically — nothing else to change. A config that was provisioned but fails to verify is treated as an active tampering signal: the app terminates on startup, unconditionally, regardless of what any individual check's policy says. A project that never requests a signed config, or never adds the signedPolicyConfig override above, sees no difference at all.

Wire up your Application

Extend SecureApplication and hand it the values the plugin put in BuildConfig. That is the whole integration — the base class runs the checks on startup and on a randomised foreground schedule.

class MyApp : SecureApplication() {

    override val securityPolicies =
        SecurityPolicies.parse(BuildConfig.APPGUARD_POLICIES)

    override val allowedSigningCertificates =
        BuildConfig.ALLOWED_SIGNING_CERTIFICATES
            .split(",")
            .filter { it.isNotBlank() }

    override val screenProtection = ScreenProtection(
        secureWindow   = BuildConfig.APPGUARD_SECURE_WINDOW,
        touchFiltering = BuildConfig.APPGUARD_TOUCH_FILTERING,
    )
}

Register it in your manifest:

<application android:name=".MyApp" … >

The third override is what lets SecureActivity see your window settings — it reads them from the Application instance. Leave it out and both protections stay on, which is the safe direction but ignores what you set in appGuard { }.

Not using the plugin? Every one of these has a plain constructor — SecurityPolicies(signature = SecurityPolicy.ENFORCED, …) and ScreenProtection(secureWindow = false) — and the defaults are the strict ones, so forgetting to configure AppGuard fails closed.

Handle security events

Every detection is published to securityEvents, keyed by check. Events are emitted for WARNING and ENFORCED — an enforced finding is published before the exception is thrown, so you see the reason rather than only the crash.

lifecycleScope.launch {
    (application as SecureApplication).securityEvents
        .filter { it.check == SecurityCheck.ROOT }
        .collect { event ->
            Log.w("AppGuard", "${event.policy}: ${event.message}")
            if (event.isFatal) disableSensitiveFeatures()
        }
}
The flow replays. A screen that subscribes after startup still receives findings emitted during Application.onCreate, so you do not have to race the checks to observe them.

isDeviceSecure() gives the aggregate — false once an ENFORCED check has failed. Warnings do not change it.

Screen protection

Extending SecureActivity is optional and separate from the checks. It applies FLAG_SECURE (blocking screenshots, screen recording and the recents thumbnail), filters touches that arrive while another window is overlaid — the mechanism behind tapjacking — and re-validates integrity at each lifecycle entry point.

class MainActivity : SecureActivity() {

    override fun onSecureCreate(savedInstanceState: Bundle?) {
        setContentView(R.layout.activity_main)
    }

    // Called when validation fails. Terminates the task by default.
    override fun onDeviceNotSecure() {
        showBlockedScreen()
    }
}

Both protections are on by default and are configured per build type in appGuard { }, alongside the check policy:

appGuard {
    debug {
        // FLAG_SECURE makes it impossible to take a screenshot for a bug
        // report, so it is usually off while developing.
        secureWindow = false
    }

    release {
        secureWindow   = true
        touchFiltering = true
    }
}

Your Application passes them on:

override val screenProtection = ScreenProtection(
    secureWindow   = BuildConfig.APPGUARD_SECURE_WINDOW,
    touchFiltering = BuildConfig.APPGUARD_TOUCH_FILTERING,
)
SecureActivity reads this from the Application. If your app does not extend SecureApplication, the activity cannot discover the setting and applies both protections — it defends itself rather than assuming it was meant to be unprotected.

Note onSecureCreate rather than onCreate — the base class needs to apply window flags before your content view is inflated.

Play Integrity (optional)

Every check above runs entirely on-device with no network call — that is what lets a build work offline and keeps a portal outage from ever blocking a release. Google Play Integrity is deliberately kept outside that system: it is a different trust root — a hardware-attested verdict signed by Google and verified on your own backend, not another local check — offered as an addition, not a replacement.

Add the Play Integrity SDK yourself; AppGuard depends on it only as compileOnly, so nothing about it is pulled into your APK unless you ask for it:

// app/build.gradle.kts
dependencies {
    implementation("com.google.android.play:integrity:1.4.0")
}
val result = PlayIntegrityCheck.requestToken(
    context = this,
    cloudProjectNumber = 123456789L,   // Play Console → App integrity → Play Integrity API
    nonce = nonceFromYourBackend,      // fresh, unpredictable, base64url — never generated on-device
)

when (result) {
    is PlayIntegrityCheck.IntegrityResult.Token ->
        sendToYourBackendForVerification(result.token)
    is PlayIntegrityCheck.IntegrityResult.Failed ->
        logger.warn("Play Integrity request failed", result.cause)
}
The token is meaningless until your backend verifies it. requestToken only fetches it — a client can no more self-verify a Play Integrity token than a server can trust a JWT without checking its signature. Decode and verify against Google's servers before you act on the result; see Google's verification guide.

Both trust roots are worth having together, not one instead of the other: the checks above inspect local, on-device state, which a sufficiently privileged attacker can eventually lie about; a Play Integrity verdict is attested by hardware and verified server-side, which defeating requires forging that attestation chain rather than hiding local artifacts from a scoped app.

Whitelist your signing certificate

The signature check compares the running APK's signing certificate against allowedSigningCertificates. Supply SHA-256 fingerprints as colon-hex or base64; both are accepted.

./gradlew :app:signingReport
With Play App Signing, list two certificates. Your upload certificate and the Play-managed distribution certificate. Listing only the upload one means every build from the store is flagged as repackaged — and because the check is ENFORCED in release, that is every user, immediately.

The check requires every signer to be whitelisted, not just one, so an attacker cannot append their certificate to the signing block while keeping yours.

R8 and ProGuard

Nothing to add. The AAR ships its own consumer rules, which keep the JNI boundary intact — the native layer resolves its bridge class by name at load time, so that one class must survive shrinking.

Everything else is deliberately left obfuscatable. Every unobfuscated symbol in a security library is a signpost telling an attacker which method to hook, so do not add broad -keep rules for AppGuard classes.

Two options worth adding to your own app's proguard-rules.pro, if you are not already setting them: -optimizationpasses 5 (more inlining, so there is no single method to hook to disable a check) and -renamesourcefileattribute "" (keeps a stack trace from naming your package layout). Both are global R8 options, so the AAR cannot set them on your behalf the way it does for the JNI-boundary -keep rule — they have to be yours.

Policy reference

CheckDetectsdebug defaultrelease default
signatureRepackaged APK — signing certificate not whitelistedALLOWEDENFORCED
rootRootBeer signals, Magisk, permissive SELinuxWARNINGENFORCED
hookingFrida, Xposed, LSPosed/Zygisk/Riru, runtime instrumentationWARNINGENFORCED
debuggerJava or native debugger, debuggable flagALLOWEDENFORCED
emulatorEmulator rather than physical hardwareALLOWEDENFORCED
installerInstalled from an untrusted sourceALLOWEDALLOWED

installer is off even in release because it produces false positives for sideloaded enterprise builds and during QA. Turn it on once you ship exclusively through a store.

Changed in 0.1.8: hooking controls the Kotlin side only. The native watchdog that detects Frida runs from the moment the library is loaded and can no longer be switched off — not by policy, and not on a debug build. If you attach Frida to a debug build, the process is terminated regardless of hooking = SecurityPolicy.WARNING. Previously a debug build could disable it. Use a build variant without AppGuard applied if you need to instrument your own app.

Window protection

Set in the same debug { } and release { } blocks, but booleans rather than policies — a window flag is either applied or it is not.

SettingDoesdebug defaultrelease default
secureWindow FLAG_SECURE: blocks screenshots, recording, recents thumbnail and non-secure displays truetrue
touchFiltering Discards touches delivered while another window overlays the activity (tapjacking) truetrue

Both stay on by default in every build type, unlike the checks above. Most projects set debug { secureWindow = false } so screenshots work while developing.

Troubleshooting

“variant … is not licensed”

The variant's application ID is not covered. The message lists what the licence does cover; compare it against the applicationId and any applicationIdSuffix on that build type. If the suffix is new, you need a reissued licence.

“AppGuard requires a licence key”

Nothing supplied one. The message lists all four sources — the usual cause is a key set in ~/.gradle/gradle.properties on a developer machine but not in CI.

“The AppGuard licence expired on …”

Licences are time-limited and verified offline, so there is no grace period and no way to extend one without a new key. Quote the licence id from the message when you get in touch.

Signature check fails on a store build only

Almost always Play App Signing: the APK users receive is signed by Google's distribution certificate, not your upload certificate. Add both.

Findings on every debug launch

Expected if the debug policy is stricter than the defaults. A debug build is signed with the debug keystore, is sideloaded, is often on an emulator and frequently has a debugger attached — all four are findings unless set to ALLOWED.

The app dies at startup in release with no useful stack trace

An ENFORCED finding throws DeviceIntegrityException and then kills the process shortly afterwards, so an uncaught-exception handler cannot swallow it and keep a compromised app running. Collect securityEvents to see the reason — it is published before the throw.

Ready to start?