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
| minSdk | 30 |
|---|---|
| compileSdk | 37 or newer |
| Android Gradle Plugin | 9.1.0 or newer |
| Gradle | The version your AGP requires, running on JDK 17 or newer |
| Kotlin | 2.2 or newer (AGP 9's built-in Kotlin qualifies) |
| ABIs | arm64-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.
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:
- your licence key and where to keep it;
- your release certificate's SHA-256 fingerprint, plus Google's if you use Play App Signing;
- whether to keep the
QUERY_ALL_PACKAGESpermission; - which activities get screen protection;
- how you distribute the app.
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.
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.
| Tier | For | Term | Price |
|---|---|---|---|
| Development | Evaluation, non-production builds | 3 months | Free |
| Startup | Production, under 10,000 users | 12 months | €100 / year |
| Enterprise | Production, 10,000 users or more | 12 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 script | appGuard { licenseKey = "AG1.…" } |
|---|---|
| 2. Gradle property | appguard.licenseKey=AG1.… |
| 3. Environment | APPGUARD_LICENSE_KEY=AG1.… |
| 4. File | appguard.license in the module or root project directory |
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
}
}
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.
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()
}
}
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,
)
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)
}
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
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
| Check | Detects | debug default | release default |
|---|---|---|---|
signature | Repackaged APK — signing certificate not whitelisted | ALLOWED | ENFORCED |
root | RootBeer signals, Magisk, permissive SELinux | WARNING | ENFORCED |
hooking | Frida, Xposed, LSPosed/Zygisk/Riru, runtime instrumentation | WARNING | ENFORCED |
debugger | Java or native debugger, debuggable flag | ALLOWED | ENFORCED |
emulator | Emulator rather than physical hardware | ALLOWED | ENFORCED |
installer | Installed from an untrusted source | ALLOWED | ALLOWED |
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.
| Setting | Does | debug default | release default |
|---|---|---|---|
secureWindow |
FLAG_SECURE: blocks screenshots, recording, recents thumbnail and non-secure displays | true | true |
touchFiltering |
Discards touches delivered while another window overlays the activity (tapjacking) | true | true |
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.